From 2cff5fe513ca74a3716b96e79bc714e25bf271fb Mon Sep 17 00:00:00 2001 From: "Tom C." Date: Sat, 4 Jul 2026 02:53:38 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20r=C3=A9=C3=A9criture=20Go=20du=20se?= =?UTF-8?q?rveur=20MCP=20(client=20de=20l'API=20/v1)=20=E2=80=94=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Porte le serveur MCP de Next.js/TS vers Go dans go/ (le TS reste intact à la racine). Le serveur devient un client HTTP mince de footics-api /v1 : il vérifie le JWT Supabase localement (double-mode JWKS/HS256, même logique que footics-api internal/auth) puis relaie le Bearer vers /v1. Zéro accès DB. - SDK officiel github.com/modelcontextprotocol/go-sdk v1.6.1 (Streamable HTTP stateless + auth.RequireBearerToken + PRM RFC 9728 sur les 2 chemins well-known) - 9 outils de lecture mappés sur /v1, formes de sortie GELÉES (identiques au TS lib/tools.ts) ; inputSchema fixés explicitement - submit_prediction = stub gated OFF (POST /v1/predictions = M4, non livré) - Tests : unit (auth double-mode, mapping de forme par outil vs contrat TS) + intégration contre l'API staging (skip propre si injoignable) Gate vert : go build && go vet && gofmt -l (vide) && go test ./... Validé à la main contre l'API staging (127.0.0.1:8099). Claude-Session: https://claude.ai/code/session_01CsRaJpQmoPyXRqH7g29mwN --- go/.env.example | 43 +++ go/README.md | 116 ++++++++ go/cmd/mcp/main.go | 190 ++++++++++++++ go/go.mod | 17 ++ go/go.sum | 20 ++ go/internal/apiclient/apiclient.go | 276 +++++++++++++++++++ go/internal/auth/auth.go | 238 +++++++++++++++++ go/internal/auth/auth_test.go | 85 ++++++ go/internal/config/config.go | 127 +++++++++ go/internal/config/config_test.go | 72 +++++ go/internal/tools/integration_test.go | 307 ++++++++++++++++++++++ go/internal/tools/ratelimit.go | 72 +++++ go/internal/tools/schemas.go | 137 ++++++++++ go/internal/tools/shapes.go | 328 +++++++++++++++++++++++ go/internal/tools/shapes_test.go | 160 +++++++++++ go/internal/tools/tools.go | 364 ++++++++++++++++++++++++++ 16 files changed, 2552 insertions(+) create mode 100644 go/.env.example create mode 100644 go/README.md create mode 100644 go/cmd/mcp/main.go create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/internal/apiclient/apiclient.go create mode 100644 go/internal/auth/auth.go create mode 100644 go/internal/auth/auth_test.go create mode 100644 go/internal/config/config.go create mode 100644 go/internal/config/config_test.go create mode 100644 go/internal/tools/integration_test.go create mode 100644 go/internal/tools/ratelimit.go create mode 100644 go/internal/tools/schemas.go create mode 100644 go/internal/tools/shapes.go create mode 100644 go/internal/tools/shapes_test.go create mode 100644 go/internal/tools/tools.go diff --git a/go/.env.example b/go/.env.example new file mode 100644 index 0000000..c1325d5 --- /dev/null +++ b/go/.env.example @@ -0,0 +1,43 @@ +# Footics MCP (Go) — environment variables +# Copy to .env and export before running ./bin/mcp. No DATABASE_URL: this server +# is a thin client of footics-api /v1, it never touches Postgres. + +# footics-api base URL. In the compose network this is the internal address; for +# local dev point it at the staging API. The client appends /v1/... and relays +# the caller's Bearer. Required. +FOOTICS_API_URL="http://127.0.0.1:8099" + +# JWT verification mode — set EXACTLY ONE (mirrors footics-api internal/auth): +# AUTH_JWKS_URL → asymmetric ES256/RS256, keys fetched + cached from the URL (prod) +# AUTH_JWT_SECRET → HS256 with the legacy Supabase secret (staging/dev) +# AUTH_JWKS_URL="https://.supabase.co/auth/v1/.well-known/jwks.json" +AUTH_JWT_SECRET="" +AUTH_JWT_AUD="authenticated" + +# Public origin of this server; Resource = MCP_PUBLIC_URL + "/mcp" (the OAuth +# protected-resource identifier advertised in the RFC 9728 metadata). +MCP_PUBLIC_URL="https://mcp.footics.app" + +# OAuth issuer advertised as authorization_servers[0] in the metadata (Supabase). +# Empty → the field is omitted. +MCP_OAUTH_ISSUER="https://.supabase.co/auth/v1" + +# HTTP listen address. +HTTP_ADDR=":8080" + +# Kill switch: "false" → every /mcp request gets 503 without touching auth/API. +MCP_ENABLED="true" + +# Require auth? "true" (default). "false" = authless test mode (identity from +# MCP_TEST_USER_ID; reads still need a relayed Bearer, so the API will reject them). +MCP_REQUIRE_AUTH="true" + +# Expose submit_prediction as a real write? Its real path lands with footics-api +# POST /v1/predictions (M4); until then the tool is a stub regardless. +MCP_ENABLE_WRITES="false" + +# Per-user tool-call rate limit (calls/minute/user). 0 = off. Default 30. +MCP_RATE_LIMIT_PER_MIN="30" + +# Identity fallback for authless test mode (MCP_REQUIRE_AUTH=false). +# MCP_TEST_USER_ID="" diff --git a/go/README.md b/go/README.md new file mode 100644 index 0000000..398ecb1 --- /dev/null +++ b/go/README.md @@ -0,0 +1,116 @@ +# Footics MCP — Go rewrite (client of `/v1`) + +Go port of the Footics MCP server. It is a **thin HTTP client of `footics-api` +`/v1`**: it verifies the Supabase JWT locally, then relays the caller's Bearer to +the API — **no database access**. This is the staging deliverable for PRD 15 +(`docs/prd/15-mcp-go.md`); it lives in `go/` alongside the untouched Next.js/TS +server at the repo root. + +> **The TypeScript server stays.** Nothing at the repo root is removed or moved. +> The live server (`mcp.footics.app` on Vercel) is unaffected. Whether/when Go +> replaces TS is **Tom's call** — see [Repo structure](#repo-structure). + +## What it does + +- Verifies the Supabase JWT **locally**, double-mode (JWKS ES256/RS256 or HS256), + same logic as `footics-api/internal/auth`. +- Serves RFC 9728 **protected-resource metadata** on both well-known paths + the + `WWW-Authenticate` challenge on 401 → OAuth discovery still points at Supabase. +- Exposes the **10 frozen tools** over Streamable HTTP (stateless). Each read is + one `/v1` call with the Bearer relayed; the output shape is byte-identical to + the TS server (`lib/tools.ts`). `whoami` is served from the JWT alone. + +## Quickstart + +```bash +cd go +cp .env.example .env # point FOOTICS_API_URL at the staging API +set -a && . ./.env && set +a +go run ./cmd/mcp # listens on HTTP_ADDR (default :8080) +``` + +Gate (what CI runs): + +```bash +go build ./... && go vet ./... && gofmt -l . # last must print nothing +go test ./... +``` + +The integration test (`internal/tools/integration_test.go`) stands up the real +stack in front of a running staging API (`http://127.0.0.1:8099`), mints an alice +Bearer, and asserts the frozen shape of every read tool. It **skips** cleanly +when the API is unreachable. + +## Environment + +| Var | Required | Default | Notes | +|---|---|---|---| +| `FOOTICS_API_URL` | ✅ | — | Base URL of `footics-api` (`/v1` appended). No `DATABASE_URL` exists. | +| `AUTH_JWKS_URL` \| `AUTH_JWT_SECRET` | ✅ (exactly one) | — | JWKS (prod) or HS256 (staging). Mutually exclusive. | +| `AUTH_JWT_AUD` | | `authenticated` | Expected `aud` claim. | +| `MCP_PUBLIC_URL` | | `https://mcp.footics.app` | `Resource` = this + `/mcp`. | +| `MCP_OAUTH_ISSUER` | | — | `authorization_servers[0]` in the metadata (Supabase). | +| `HTTP_ADDR` | | `:8080` | | +| `MCP_ENABLED` | | `true` | `false` → 503 kill switch. | +| `MCP_REQUIRE_AUTH` | | `true` | `false` → authless test mode (`MCP_TEST_USER_ID`). | +| `MCP_ENABLE_WRITES` | | `false` | See [submit_prediction](#submit_prediction-awaits-m4). | +| `MCP_RATE_LIMIT_PER_MIN` | | `30` | Per-user; 0 = off. Global on this single process. | + +## Tools → `/v1` endpoints + +| Tool | Endpoint | Notes | +|---|---|---| +| `whoami` | — (JWT) | `{userId, email}` from the token. | +| `list_matches` | `GET /v1/matches?competition=&status=&limit=` | flat array, projected to the frozen `MatchJson`. | +| `get_match` | `GET /v1/matches/{id}` | + `events[]` (`[]` when scheduled); 404 → "Match introuvable." | +| `get_my_standing` | `GET /v1/me?competition=` | projects `{userId,username,competition,points,exact,rank,rankOf}`; 404 → "Profil introuvable." | +| `get_my_predictions` | `GET /v1/predictions?competition=&when=&limit=` | drops `winnerTeamCode`; `result`/`points` present-null. | +| `get_leaderboard` | `GET /v1/leaderboard?competition=&group=&limit=` | projects `rows`. | +| `list_my_groups` | `GET /v1/groups?competition=` | projects `groups` (`owner` bool). | +| `get_joker_status` | `GET /v1/jokers?competition=` | passthrough. | +| `search` | `GET /v1/search?q=` | passthrough (`{matches,groups,users}`). | +| `submit_prediction` | `POST /v1/predictions` | **stub — awaits M4.** | + +Projection notes (to stay byte-identical to `lib/tools.ts`): +- **`kickoffAt`** is normalised to `Date.toISOString()` form (always `.000Z`) — + `/v1/matches` already emits millis, `/v1/predictions` and `/v1/search` do not. +- **`venue`** empty string → `null` (the TS `venue ?? null` for an absent venue). +- **`score`** = the regulation `Score` for finished, `LiveScore` for live, else `null`. +- Input schemas are **fixed explicitly** (`internal/tools/schemas.go`) — enums, + bounds, defaults and FR descriptions — not inferred from Go structs, so + `tools/list` matches the zod-derived TS schemas. + +### `submit_prediction` awaits M4 + +The write path (`footics-api POST /v1/predictions`) ships with **M4** and is not +delivered yet. The tool is registered (prod runs writes ON) but is a **stub**: +with `MCP_ENABLE_WRITES=false` it returns *"écriture pas encore disponible sur +cette instance"*; with writes on it returns a *"arrivera avec l'API /v1 POST +(M4)"* error. Wiring the real call is a one-function change once M4 lands. + +## Layout + +``` +go/ + cmd/mcp/ server wiring (mux, PRM, auth middleware, CORS, kill switch, health) + internal/config/ env parsing (no DATABASE_URL) + internal/auth/ JWT double-mode verifier → go-sdk auth.TokenVerifier + internal/apiclient/ thin /v1 HTTP client (relays the Bearer) + internal/tools/ 10 tools: registration, gate (auth+rate-limit), projections, schemas +``` + +Uses the official SDK `github.com/modelcontextprotocol/go-sdk` (v1.6.1): +`mcp.NewStreamableHTTPHandler` (stateless), `mcp.AddTool`, `auth.RequireBearerToken`, +`auth.ProtectedResourceMetadataHandler`. Routing is stdlib `net/http.ServeMux` +(five static routes — chi would buy nothing here). + +## Repo structure + +Delivered under `go/` so the branch is **purely additive**: not a single TS file +at the repo root is moved or deleted, and the live Vercel server is untouched. +PRD 15 §9 envisions an eventual in-place rewrite (TS → history + a `v0-nextjs` +tag); promoting `go/` to the root and archiving the TS is a **separate decision +for Tom**, out of scope for this staging deliverable. + +Not included here (land with the cutover, N4): Dockerfile / GHCR image, Go CI +workflow, tunnel + compose service. See PRD 15 §10–11. diff --git a/go/cmd/mcp/main.go b/go/cmd/mcp/main.go new file mode 100644 index 0000000..0d503e3 --- /dev/null +++ b/go/cmd/mcp/main.go @@ -0,0 +1,190 @@ +// Command mcp is the Footics MCP server: a thin client of footics-api /v1. +// +// It verifies the Supabase JWT locally (double-mode JWKS/HS256), serves the RFC +// 9728 protected-resource metadata + WWW-Authenticate challenge so OAuth +// discovery points at Supabase, and exposes the 10 frozen tools over Streamable +// HTTP (stateless). Every read relays the caller's Bearer to footics-api; there +// is no database access here. +package main + +import ( + "context" + "encoding/json" + "log" + "log/slog" + "net/http" + "os" + "time" + + sdkauth "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/modelcontextprotocol/go-sdk/oauthex" + + "github.com/footics/mcp-server/internal/apiclient" + "github.com/footics/mcp-server/internal/auth" + "github.com/footics/mcp-server/internal/config" + "github.com/footics/mcp-server/internal/tools" +) + +const version = "0.1.0-go" + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))) + + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + api := apiclient.New(cfg.APIBaseURL) + + server := mcp.NewServer(&mcp.Implementation{Name: "footics-mcp", Version: version}, nil) + tools.Register(server, tools.Deps{ + API: api, + EnableWrites: cfg.EnableWrites, + TestUserID: cfg.TestUserID, + RateLimitPerMin: cfg.RateLimitPerMin, + }) + + mux := http.NewServeMux() + mux.Handle("/mcp", mcpHandler(cfg, server)) + mux.Handle("/.well-known/oauth-protected-resource", protectedResourceHandler(cfg)) + mux.Handle("/.well-known/oauth-protected-resource/mcp", protectedResourceHandler(cfg)) + mux.HandleFunc("/health", healthHandler(cfg, api)) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) + }) + + mode := "hs256" + if cfg.UseJWKS() { + mode = "jwks" + } + slog.Info("footics-mcp starting", + "addr", cfg.HTTPAddr, "resource", cfg.Resource, "apiBaseURL", cfg.APIBaseURL, + "authMode", mode, "requireAuth", cfg.RequireAuth, "enabled", cfg.Enabled, + "writes", cfg.EnableWrites, "rateLimitPerMin", cfg.RateLimitPerMin, + ) + // ponytail: bare ListenAndServe — graceful shutdown lands with the container + // lifecycle at cutover (N4). Nothing here holds state worth draining. + log.Fatal(http.ListenAndServe(cfg.HTTPAddr, mux)) +} + +// mcpHandler wraps the Streamable HTTP handler with the kill switch, bearer auth +// (when required), and CORS. Order (outer→inner): CORS → kill switch → auth → MCP. +func mcpHandler(cfg config.Config, server *mcp.Server) http.Handler { + streamable := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return server }, + &mcp.StreamableHTTPOptions{Stateless: true}, + ) + + var h http.Handler = streamable + if cfg.RequireAuth { + verifier := auth.NewVerifier(cfg) + h = sdkauth.RequireBearerToken(verifier.Verify, &sdkauth.RequireBearerTokenOptions{ + ResourceMetadataURL: cfg.PublicURL + "/.well-known/oauth-protected-resource/mcp", + })(h) + } + if !cfg.Enabled { + h = http.HandlerFunc(serviceOff) + } + return withCORS(h) +} + +// protectedResourceHandler serves the RFC 9728 metadata (authorization_servers → +// Supabase). Served identically on both well-known paths. +func protectedResourceHandler(cfg config.Config) http.Handler { + var servers []string + if cfg.OAuthIssuer != "" { + servers = []string{cfg.OAuthIssuer} + } + return sdkauth.ProtectedResourceMetadataHandler(&oauthex.ProtectedResourceMetadata{ + Resource: cfg.Resource, + AuthorizationServers: servers, + BearerMethodsSupported: []string{"header"}, + ScopesSupported: []string{"openid", "email", "profile"}, + ResourceName: "Footics MCP", + ResourceDocumentation: "https://footics.app", + }) +} + +// serviceOff is the MCP_ENABLED=false response: a JSON-RPC error, no auth/API +// traffic (mirrors the TS kill switch). +func serviceOff(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "3600") + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + "jsonrpc": "2.0", + "id": nil, + "error": map[string]any{ + "code": -32000, + "message": "Footics MCP est temporairement hors service (maintenance). Réessaie plus tard.", + }, + }) +} + +// healthHandler reports config + a best-effort footics-api liveness probe. Unlike +// the TS server there is no `db` section — the DB lives behind footics-api now. +func healthHandler(cfg config.Config, api *apiclient.Client) http.HandlerFunc { + mode := "hs256" + if cfg.UseJWKS() { + mode = "jwks" + } + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if !cfg.Enabled { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, "enabled": false, "resource": cfg.Resource, + "reason": "MCP_ENABLED=false — service coupé volontairement (maintenance).", + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "enabled": true, + "resource": cfg.Resource, + "auth": map[string]any{"required": cfg.RequireAuth, "mode": mode}, + "writes": cfg.EnableWrites, + "rateLimitPerMin": cfg.RateLimitPerMin, + "api": map[string]any{"url": cfg.APIBaseURL, "ok": probeAPI(r.Context(), cfg.APIBaseURL)}, + }) + } +} + +// probeAPI does a bounded GET of footics-api /healthz. +func probeAPI(ctx context.Context, base string) bool { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/healthz", nil) + if err != nil { + return false + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// withCORS mirrors the TS CORS contract (browser MCP clients need it) and answers +// the OPTIONS preflight. WWW-Authenticate is exposed so 401 discovery works +// cross-origin. +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Mcp-Session-Id, MCP-Protocol-Version, Accept") + h.Set("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..cfd8f04 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,17 @@ +module github.com/footics/mcp-server + +go 1.25.2 + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/jsonschema-go v0.4.3 + github.com/modelcontextprotocol/go-sdk v1.6.1 +) + +require ( + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..2d6f186 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,20 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +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/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= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= diff --git a/go/internal/apiclient/apiclient.go b/go/internal/apiclient/apiclient.go new file mode 100644 index 0000000..3da4629 --- /dev/null +++ b/go/internal/apiclient/apiclient.go @@ -0,0 +1,276 @@ +// Package apiclient is a thin HTTP client of footics-api /v1. It relays the +// caller's Bearer verbatim (footics-api re-verifies as the trust boundary and +// scopes the data) and adds ?competition= and the per-tool query params. It has +// NO database access; footics-api owns all reads. +// +// The response structs decode only the fields the MCP tools project into their +// frozen output shapes; extra /v1 view-model fields are ignored. +package apiclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// requestTimeout bounds each /v1 call. The API is one internal hop in prod. +const requestTimeout = 10 * time.Second + +// Client calls footics-api /v1. +type Client struct { + base string + hc *http.Client +} + +// New builds a client for the given base URL (e.g. http://api:8080). +func New(baseURL string) *Client { + return &Client{base: baseURL, hc: &http.Client{Timeout: requestTimeout}} +} + +/* ── /v1 response shapes (only the projected fields) ───────────────────────── */ + +type Score struct { + H int `json:"h"` + A int `json:"a"` +} + +type Team struct { + Code string `json:"code"` + Name string `json:"name"` +} + +type LiveClock struct { + Period string `json:"period"` + DisplayMinute *int `json:"displayMinute"` + Running bool `json:"running"` +} + +// Match mirrors the /v1 matchJSON fields the MCP needs. +type Match struct { + ID string `json:"id"` + Phase string `json:"phase"` + Group *string `json:"group"` + Status string `json:"status"` + KickoffAt string `json:"kickoffAt"` + Venue string `json:"venue"` + Competition struct { + Kind string `json:"kind"` + } `json:"competition"` + Home Team `json:"home"` + Away Team `json:"away"` + Score *Score `json:"score"` + LiveScore *Score `json:"liveScore"` + Live *LiveClock `json:"live"` + Mine *Score `json:"mine"` + JokerApplied *bool `json:"jokerApplied"` + Points *int `json:"points"` +} + +type Event struct { + Type string `json:"type"` + Period string `json:"period"` + Minute int `json:"minute"` + MinutePlus *int `json:"minutePlus"` + TeamCode *string `json:"teamCode"` + Player *string `json:"player"` + Detail *string `json:"detail"` +} + +// MatchDetail is a Match plus its event timeline. +type MatchDetail struct { + Match + Events []Event `json:"events"` +} + +// Me mirrors the /v1/me MeProfile subset the MCP projects. +type Me struct { + ID string `json:"id"` + Username string `json:"username"` + Points int `json:"points"` + Exact int `json:"exact"` + Rank int `json:"rank"` + RankOf int `json:"rankOf"` +} + +type Prediction struct { + MatchID string `json:"matchId"` + Fixture string `json:"fixture"` + KickoffAt string `json:"kickoffAt"` + Status string `json:"status"` + Prediction struct { + Home int `json:"home"` + Away int `json:"away"` + Joker bool `json:"joker"` + } `json:"prediction"` + Result *struct { + Home int `json:"home"` + Away int `json:"away"` + } `json:"result"` + Points *int `json:"points"` +} + +type LeaderRow struct { + Rank int `json:"rank"` + Username string `json:"username"` + Points int `json:"points"` + Exact int `json:"exact"` + Me bool `json:"me"` +} + +type Group struct { + ID string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Members int `json:"members"` + MyRank int `json:"myRank"` + MyPoints int `json:"myPoints"` + Owner *string `json:"owner"` +} + +type Joker struct { + Bucket string `json:"bucket"` + Label string `json:"label"` + Used int `json:"used"` + Quota int `json:"quota"` + Remaining int `json:"remaining"` +} + +type SearchResults struct { + Matches []struct { + ID string `json:"id"` + Fixture string `json:"fixture"` + KickoffAt string `json:"kickoffAt"` + } `json:"matches"` + Groups []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"groups"` + Users []struct { + ID string `json:"id"` + Username string `json:"username"` + Me bool `json:"me"` + } `json:"users"` +} + +/* ── typed calls ───────────────────────────────────────────────────────────── */ + +// Matches → GET /v1/matches?competition=&status=&limit=. +func (c *Client) Matches(ctx context.Context, token, comp, status string, limit int) ([]Match, error) { + q := url.Values{"competition": {comp}, "limit": {strconv.Itoa(limit)}} + if status != "" { + q.Set("status", status) + } + var out []Match + _, err := c.getJSON(ctx, token, "/v1/matches", q, &out) + return out, err +} + +// Match → GET /v1/matches/{id}. found=false on 404. +func (c *Client) Match(ctx context.Context, token, id string) (*MatchDetail, bool, error) { + var out MatchDetail + code, err := c.getJSON(ctx, token, "/v1/matches/"+url.PathEscape(id), nil, &out) + if code == http.StatusNotFound { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return &out, true, nil +} + +// Me → GET /v1/me?competition=. found=false on 404. +func (c *Client) Me(ctx context.Context, token, comp string) (*Me, bool, error) { + var out struct { + Me *Me `json:"me"` + } + code, err := c.getJSON(ctx, token, "/v1/me", url.Values{"competition": {comp}}, &out) + if code == http.StatusNotFound { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + if out.Me == nil { + return nil, false, nil + } + return out.Me, true, nil +} + +// Predictions → GET /v1/predictions?competition=&when=&limit=. +func (c *Client) Predictions(ctx context.Context, token, comp, when string, limit int) ([]Prediction, error) { + q := url.Values{"competition": {comp}, "when": {when}, "limit": {strconv.Itoa(limit)}} + var out []Prediction + _, err := c.getJSON(ctx, token, "/v1/predictions", q, &out) + return out, err +} + +// Leaderboard → GET /v1/leaderboard?competition=&group=&limit=; returns rows. +func (c *Client) Leaderboard(ctx context.Context, token, comp, groupID string, limit int) ([]LeaderRow, error) { + q := url.Values{"competition": {comp}, "limit": {strconv.Itoa(limit)}} + if groupID != "" { + q.Set("group", groupID) + } + var out struct { + Rows []LeaderRow `json:"rows"` + } + _, err := c.getJSON(ctx, token, "/v1/leaderboard", q, &out) + return out.Rows, err +} + +// Groups → GET /v1/groups?competition=; returns the caller's groups. +func (c *Client) Groups(ctx context.Context, token, comp string) ([]Group, error) { + var out struct { + Groups []Group `json:"groups"` + } + _, err := c.getJSON(ctx, token, "/v1/groups", url.Values{"competition": {comp}}, &out) + return out.Groups, err +} + +// Jokers → GET /v1/jokers?competition=. +func (c *Client) Jokers(ctx context.Context, token, comp string) ([]Joker, error) { + var out []Joker + _, err := c.getJSON(ctx, token, "/v1/jokers", url.Values{"competition": {comp}}, &out) + return out, err +} + +// Search → GET /v1/search?q=. +func (c *Client) Search(ctx context.Context, token, query string) (SearchResults, error) { + var out SearchResults + _, err := c.getJSON(ctx, token, "/v1/search", url.Values{"q": {query}}, &out) + return out, err +} + +// getJSON does an authenticated GET and decodes a 2xx body into dst. It returns +// the HTTP status (so callers can special-case 404) and a non-nil error on any +// non-2xx status or transport/decode failure. +func (c *Client) getJSON(ctx context.Context, token, path string, q url.Values, dst any) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil) + if err != nil { + return 0, err + } + if q != nil { + req.URL.RawQuery = q.Encode() + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + resp, err := c.hc.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<22)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return resp.StatusCode, fmt.Errorf("footics-api GET %s: status %d", path, resp.StatusCode) + } + if err := json.Unmarshal(body, dst); err != nil { + return resp.StatusCode, fmt.Errorf("footics-api GET %s: decode: %w", path, err) + } + return resp.StatusCode, nil +} diff --git a/go/internal/auth/auth.go b/go/internal/auth/auth.go new file mode 100644 index 0000000..9728ce3 --- /dev/null +++ b/go/internal/auth/auth.go @@ -0,0 +1,238 @@ +// Package auth verifies Supabase-issued user JWTs (double-mode: JWKS or HS256) +// and adapts the result to the go-sdk auth.TokenVerifier contract. +// +// The verification logic mirrors footics-api internal/auth (same aud + exp + +// method checks, same JWKS cache with a refresh cooldown). The MCP server uses +// the extracted identity only to (a) answer whoami and key the rate-limiter and +// (b) relay the caller's Bearer to footics-api, which re-verifies as the trust +// boundary. It grants no data access on its own. +package auth + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + sdkauth "github.com/modelcontextprotocol/go-sdk/auth" + + "github.com/footics/mcp-server/internal/config" +) + +const ( + jwksTTL = time.Hour + jwksRefreshCooldown = 30 * time.Second + jwksMaxBody = 1 << 20 +) + +// ExtraEmail / ExtraToken are the keys under which the verifier stashes the +// caller's email and raw Bearer in TokenInfo.Extra (the latter is relayed to +// footics-api by the tool handlers). +const ( + ExtraEmail = "email" + ExtraToken = "token" +) + +// Verifier verifies Supabase user JWTs. Mode is fixed at boot: JWKS (asymmetric +// ES256/RS256, keys fetched from AUTH_JWKS_URL) or HS256 (AUTH_JWT_SECRET). +type Verifier struct { + secret []byte + jwks *jwksCache + opts []jwt.ParserOption + useHS bool +} + +// NewVerifier builds the verifier from config (exactly one mode selected). +func NewVerifier(cfg config.Config) *Verifier { + common := []jwt.ParserOption{ + jwt.WithAudience(cfg.JWTAud), + jwt.WithExpirationRequired(), + } + if cfg.UseJWKS() { + return &Verifier{ + jwks: newJWKSCache(cfg.JWKSURL), + opts: append(common, jwt.WithValidMethods([]string{"ES256", "RS256"})), + } + } + return &Verifier{ + secret: []byte(cfg.JWTSecret), + useHS: true, + opts: append(common, jwt.WithValidMethods([]string{"HS256"})), + } +} + +// Verify satisfies the go-sdk auth.TokenVerifier signature. On success it returns +// a TokenInfo carrying the user id, expiration, and (in Extra) the email + the +// raw Bearer for relay. On any failure it returns an error unwrapping to +// sdkauth.ErrInvalidToken so the middleware answers 401 + WWW-Authenticate. +func (v *Verifier) Verify(_ context.Context, token string, _ *http.Request) (*sdkauth.TokenInfo, error) { + claims := jwt.MapClaims{} + parsed, err := jwt.ParseWithClaims(token, claims, v.keyfunc, v.opts...) + if err != nil || !parsed.Valid { + return nil, fmt.Errorf("%w: %v", sdkauth.ErrInvalidToken, err) + } + sub, _ := claims["sub"].(string) // Supabase `sub` = user UUID + if sub == "" { + return nil, fmt.Errorf("%w: missing sub", sdkauth.ErrInvalidToken) + } + exp, err := claims.GetExpirationTime() + if err != nil || exp == nil { + return nil, fmt.Errorf("%w: missing exp", sdkauth.ErrInvalidToken) + } + email, _ := claims["email"].(string) + return &sdkauth.TokenInfo{ + UserID: sub, + Expiration: exp.Time, + Extra: map[string]any{ExtraEmail: email, ExtraToken: token}, + }, nil +} + +// keyfunc supplies the verification key for the token's algorithm, refusing any +// algorithm that does not belong to the configured mode. +func (v *Verifier) keyfunc(t *jwt.Token) (any, error) { + switch t.Method.(type) { + case *jwt.SigningMethodHMAC: + if !v.useHS { + return nil, jwt.ErrTokenSignatureInvalid + } + return v.secret, nil + case *jwt.SigningMethodECDSA, *jwt.SigningMethodRSA: + if v.useHS { + return nil, jwt.ErrTokenSignatureInvalid + } + kid, _ := t.Header["kid"].(string) + return v.jwks.key(kid) + default: + return nil, jwt.ErrTokenSignatureInvalid + } +} + +// --- JWKS cache (copied from footics-api internal/auth) --------------------- + +type jwksCache struct { + url string + hc *http.Client + + mu sync.Mutex + keys map[string]crypto.PublicKey + expiry time.Time + lastRefresh time.Time +} + +func newJWKSCache(url string) *jwksCache { + return &jwksCache{url: url, hc: &http.Client{Timeout: 5 * time.Second}, keys: map[string]crypto.PublicKey{}} +} + +func (c *jwksCache) key(kid string) (crypto.PublicKey, error) { + c.mu.Lock() + defer c.mu.Unlock() + if k, ok := c.keys[kid]; ok && time.Now().Before(c.expiry) { + return k, nil + } + // Cooldown: an unknown kid inside the window is rejected without a fetch, so a + // spray of bogus kids cannot turn into one origin request each. + if time.Since(c.lastRefresh) < jwksRefreshCooldown { + return nil, fmt.Errorf("unknown key id %q", kid) + } + if err := c.refresh(); err != nil { + return nil, err + } + if k, ok := c.keys[kid]; ok { + return k, nil + } + return nil, fmt.Errorf("unknown key id %q", kid) +} + +func (c *jwksCache) refresh() error { + c.lastRefresh = time.Now() // attempts count too: failures must not bypass the cooldown + + req, err := http.NewRequest(http.MethodGet, c.url, nil) + if err != nil { + return err + } + resp, err := c.hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("jwks: %s returned %d", c.url, resp.StatusCode) + } + var set struct { + Keys []jwk `json:"keys"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, jwksMaxBody)).Decode(&set); err != nil { + return fmt.Errorf("jwks: decode %s: %w", c.url, err) + } + keys := make(map[string]crypto.PublicKey, len(set.Keys)) + for _, k := range set.Keys { + pub, err := k.publicKey() + if err != nil { + continue // skip an unsupported key, keep the usable ones + } + keys[k.Kid] = pub + } + if len(keys) == 0 { + return fmt.Errorf("jwks: no usable keys at %s", c.url) + } + c.keys = keys + c.expiry = time.Now().Add(jwksTTL) + return nil +} + +// jwk is one JSON Web Key (subset: EC/P-256 and RSA public keys). +type jwk struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Crv string `json:"crv"` + N string `json:"n"` + E string `json:"e"` + X string `json:"x"` + Y string `json:"y"` +} + +func (k jwk) publicKey() (crypto.PublicKey, error) { + switch k.Kty { + case "EC": + if k.Crv != "P-256" { + return nil, fmt.Errorf("unsupported EC curve %q", k.Crv) + } + x, err := b64(k.X) + if err != nil { + return nil, err + } + y, err := b64(k.Y) + if err != nil { + return nil, err + } + return &ecdsa.PublicKey{Curve: elliptic.P256(), X: new(big.Int).SetBytes(x), Y: new(big.Int).SetBytes(y)}, nil + case "RSA": + n, err := b64(k.N) + if err != nil { + return nil, err + } + e, err := b64(k.E) + if err != nil { + return nil, err + } + exp := 0 + for _, b := range e { + exp = exp<<8 | int(b) + } + return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: exp}, nil + default: + return nil, fmt.Errorf("unsupported key type %q", k.Kty) + } +} + +func b64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) } diff --git a/go/internal/auth/auth_test.go b/go/internal/auth/auth_test.go new file mode 100644 index 0000000..9b034b3 --- /dev/null +++ b/go/internal/auth/auth_test.go @@ -0,0 +1,85 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + sdkauth "github.com/modelcontextprotocol/go-sdk/auth" + + "github.com/footics/mcp-server/internal/config" +) + +const testSecret = "dev-staging-hs256-secret-000000" + +func hsVerifier() *Verifier { + return NewVerifier(config.Config{JWTSecret: testSecret, JWTAud: "authenticated"}) +} + +func mintHS(t *testing.T, secret string, claims jwt.MapClaims) string { + t.Helper() + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) + if err != nil { + t.Fatalf("sign: %v", err) + } + return s +} + +func TestVerifyValidHS256(t *testing.T) { + tok := mintHS(t, testSecret, jwt.MapClaims{ + "sub": "user-123", "aud": "authenticated", "email": "a@b.co", + "exp": time.Now().Add(time.Hour).Unix(), + }) + ti, err := hsVerifier().Verify(context.Background(), tok, nil) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if ti.UserID != "user-123" { + t.Errorf("UserID = %q", ti.UserID) + } + if email, _ := ti.Extra[ExtraEmail].(string); email != "a@b.co" { + t.Errorf("email = %q", email) + } + if relayed, _ := ti.Extra[ExtraToken].(string); relayed != tok { + t.Error("raw token not stashed for relay") + } + if ti.Expiration.Before(time.Now()) { + t.Error("Expiration in the past") + } +} + +func TestVerifyRejects(t *testing.T) { + future := time.Now().Add(time.Hour).Unix() + cases := map[string]string{ + "wrong secret": mintHS(t, "not-the-secret", jwt.MapClaims{"sub": "u", "aud": "authenticated", "exp": future}), + "wrong aud": mintHS(t, testSecret, jwt.MapClaims{"sub": "u", "aud": "someone-else", "exp": future}), + "expired": mintHS(t, testSecret, jwt.MapClaims{"sub": "u", "aud": "authenticated", "exp": time.Now().Add(-time.Hour).Unix()}), + "missing sub": mintHS(t, testSecret, jwt.MapClaims{"aud": "authenticated", "exp": future}), + "missing exp": mintHS(t, testSecret, jwt.MapClaims{"sub": "u", "aud": "authenticated"}), + "garbage": "not.a.jwt", + } + v := hsVerifier() + for name, tok := range cases { + t.Run(name, func(t *testing.T) { + _, err := v.Verify(context.Background(), tok, nil) + if err == nil { + t.Fatal("want error, got nil") + } + if !errors.Is(err, sdkauth.ErrInvalidToken) { + t.Errorf("error %v does not unwrap to ErrInvalidToken", err) + } + }) + } +} + +func TestVerifyRejectsRS256InHSMode(t *testing.T) { + // An HS-mode verifier must refuse an asymmetric alg (alg-confusion guard). + tok := mintHS(t, testSecret, jwt.MapClaims{"sub": "u", "aud": "authenticated", "exp": time.Now().Add(time.Hour).Unix()}) + // Tamper the header alg is overkill; instead assert JWKS-mode verifier refuses HS256. + jwksV := NewVerifier(config.Config{JWKSURL: "https://example.invalid/jwks", JWTAud: "authenticated"}) + if _, err := jwksV.Verify(context.Background(), tok, nil); err == nil { + t.Fatal("JWKS-mode verifier accepted an HS256 token") + } +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go new file mode 100644 index 0000000..6ec65c8 --- /dev/null +++ b/go/internal/config/config.go @@ -0,0 +1,127 @@ +// Package config loads runtime configuration from the environment, failing +// closed with a clear error when a required value is missing or invalid. +// +// The Go MCP server is a thin client of footics-api /v1: it verifies the +// Supabase JWT locally (double-mode JWKS/HS256, same logic as footics-api +// internal/auth) and relays the caller's Bearer to the API. It has NO database +// access — there is deliberately no DATABASE_URL here. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// DefaultRateLimitPerMin is the per-user tool-call ceiling (calls/minute). +const DefaultRateLimitPerMin = 30 + +// Config holds all runtime configuration, built once at boot by Load. +type Config struct { + HTTPAddr string // HTTP_ADDR (default ":8080") + + // APIBaseURL is footics-api's base URL (FOOTICS_API_URL), e.g. + // http://api:8080 in the compose network. The client appends /v1/... paths + // and relays the caller's Bearer. Required. + APIBaseURL string + + // PublicURL is this server's public origin (MCP_PUBLIC_URL). Resource is + // PublicURL + "/mcp" — the OAuth protected-resource identifier. + PublicURL string + Resource string + + // User-auth mode, driven by which of these is set (exactly one), mirroring + // footics-api: JWKSURL selects asymmetric verification (ES256/RS256), JWTSecret + // selects HS256 (legacy Supabase secret). Aud defaults to "authenticated". + JWKSURL string // AUTH_JWKS_URL + JWTSecret string // AUTH_JWT_SECRET + JWTAud string // AUTH_JWT_AUD + + // OAuthIssuer is advertised as authorization_servers[0] in the protected + // resource metadata (MCP_OAUTH_ISSUER), e.g. https://.supabase.co/auth/v1. + // Empty → omitted from the metadata. + OAuthIssuer string + + Enabled bool // MCP_ENABLED (default true) — kill switch (503) + RequireAuth bool // MCP_REQUIRE_AUTH (default true) + EnableWrites bool // MCP_ENABLE_WRITES (default false) — submit_prediction + RateLimitPerMin int // MCP_RATE_LIMIT_PER_MIN (default 30; 0 = off) + TestUserID string // MCP_TEST_USER_ID — identity fallback when RequireAuth=false +} + +// UseJWKS reports whether the asymmetric (JWKS) verification mode is selected. +func (c Config) UseJWKS() bool { return c.JWKSURL != "" } + +// Load reads configuration from the environment, applying defaults and returning +// a clear error for any missing-required or invalid value. +func Load() (Config, error) { + public := strings.TrimRight(getenv("MCP_PUBLIC_URL", "https://mcp.footics.app"), "/") + c := Config{ + HTTPAddr: getenv("HTTP_ADDR", ":8080"), + APIBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("FOOTICS_API_URL")), "/"), + PublicURL: public, + Resource: public + "/mcp", + JWKSURL: strings.TrimSpace(os.Getenv("AUTH_JWKS_URL")), + JWTSecret: strings.TrimSpace(os.Getenv("AUTH_JWT_SECRET")), + JWTAud: getenv("AUTH_JWT_AUD", "authenticated"), + OAuthIssuer: strings.TrimSpace(os.Getenv("MCP_OAUTH_ISSUER")), + Enabled: getenvBool("MCP_ENABLED", true), + RequireAuth: getenvBool("MCP_REQUIRE_AUTH", true), + EnableWrites: getenvBool("MCP_ENABLE_WRITES", false), + TestUserID: strings.TrimSpace(os.Getenv("MCP_TEST_USER_ID")), + } + + rl, err := getenvInt("MCP_RATE_LIMIT_PER_MIN", DefaultRateLimitPerMin) + if err != nil { + return c, err + } + c.RateLimitPerMin = rl + + if c.APIBaseURL == "" { + return c, fmt.Errorf("FOOTICS_API_URL is required (base URL of footics-api /v1)") + } + + // Exactly one of AUTH_JWKS_URL / AUTH_JWT_SECRET — they select mutually + // exclusive verification modes (mirrors footics-api config.Load). + switch { + case c.JWKSURL == "" && c.JWTSecret == "": + return c, fmt.Errorf("exactly one of AUTH_JWKS_URL or AUTH_JWT_SECRET is required, got neither") + case c.JWKSURL != "" && c.JWTSecret != "": + return c, fmt.Errorf("AUTH_JWKS_URL and AUTH_JWT_SECRET are mutually exclusive, got both") + } + + return c, nil +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +// getenvBool mirrors the TS parsing: "false" (case-sensitive) disables a +// default-true flag; "true" enables a default-false flag; anything else = default. +func getenvBool(k string, def bool) bool { + v := os.Getenv(k) + if v == "" { + return def + } + if def { + return v != "false" + } + return v == "true" +} + +func getenvInt(k string, def int) (int, error) { + v := os.Getenv(k) + if v == "" { + return def, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("%s must be an integer, got %q", k, v) + } + return n, nil +} diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go new file mode 100644 index 0000000..d2f5022 --- /dev/null +++ b/go/internal/config/config_test.go @@ -0,0 +1,72 @@ +package config + +import "testing" + +func TestLoadDefaults(t *testing.T) { + t.Setenv("FOOTICS_API_URL", "http://api:8080/") + t.Setenv("AUTH_JWT_SECRET", "s3cr3t") + t.Setenv("MCP_PUBLIC_URL", "https://mcp.footics.app/") + + c, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.APIBaseURL != "http://api:8080" { + t.Errorf("APIBaseURL = %q, want trailing slash trimmed", c.APIBaseURL) + } + if c.Resource != "https://mcp.footics.app/mcp" { + t.Errorf("Resource = %q", c.Resource) + } + if !c.Enabled || !c.RequireAuth || c.EnableWrites { + t.Errorf("flags: enabled=%v requireAuth=%v writes=%v, want true/true/false", c.Enabled, c.RequireAuth, c.EnableWrites) + } + if c.RateLimitPerMin != DefaultRateLimitPerMin { + t.Errorf("RateLimitPerMin = %d, want %d", c.RateLimitPerMin, DefaultRateLimitPerMin) + } + if c.JWTAud != "authenticated" { + t.Errorf("JWTAud = %q, want authenticated", c.JWTAud) + } + if c.UseJWKS() { + t.Error("UseJWKS true with only a secret set") + } +} + +func TestLoadRequiresAPIURL(t *testing.T) { + t.Setenv("AUTH_JWT_SECRET", "s") + if _, err := Load(); err == nil { + t.Fatal("want error when FOOTICS_API_URL is unset") + } +} + +func TestLoadAuthModeExclusive(t *testing.T) { + t.Setenv("FOOTICS_API_URL", "http://api:8080") + + // neither → error + if _, err := Load(); err == nil { + t.Fatal("want error when neither JWKS nor secret is set") + } + // both → error + t.Setenv("AUTH_JWKS_URL", "https://x/jwks") + t.Setenv("AUTH_JWT_SECRET", "s") + if _, err := Load(); err == nil { + t.Fatal("want error when both JWKS and secret are set") + } +} + +func TestLoadWritesToggle(t *testing.T) { + t.Setenv("FOOTICS_API_URL", "http://api:8080") + t.Setenv("AUTH_JWT_SECRET", "s") + t.Setenv("MCP_ENABLE_WRITES", "true") + t.Setenv("MCP_ENABLED", "false") + + c, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if !c.EnableWrites { + t.Error("MCP_ENABLE_WRITES=true not honoured") + } + if c.Enabled { + t.Error("MCP_ENABLED=false not honoured") + } +} diff --git a/go/internal/tools/integration_test.go b/go/internal/tools/integration_test.go new file mode 100644 index 0000000..5e0479a --- /dev/null +++ b/go/internal/tools/integration_test.go @@ -0,0 +1,307 @@ +package tools + +// integration_test.go — end-to-end against the running staging footics-api +// (http://127.0.0.1:8099). It stands up the REAL stack (RequireBearerToken → +// Streamable HTTP → tools → /v1 relay), connects a go-sdk MCP client carrying a +// minted alice Bearer, invokes every read tool, and asserts the frozen shape on +// live data. Skips cleanly when the staging API is unreachable. +// +// Run: go test ./internal/tools/ -run TestIntegration -v + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + sdkauth "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/footics/mcp-server/internal/apiclient" + "github.com/footics/mcp-server/internal/auth" + "github.com/footics/mcp-server/internal/config" +) + +const ( + stagingURL = "http://127.0.0.1:8099" + stagingSecret = "dev-staging-hs256-secret-000000" + aliceSub = "3da4cf94-51f9-416f-9e0e-532ca33fa2a5" + aliceEmail = "alice@staging.test" +) + +// bearerRT injects the caller's Bearer on every client request (the client +// transport has no header hook otherwise). +type bearerRT struct { + token string + base http.RoundTripper +} + +func (b bearerRT) RoundTrip(r *http.Request) (*http.Response, error) { + r2 := r.Clone(r.Context()) + r2.Header.Set("Authorization", "Bearer "+b.token) + return b.base.RoundTrip(r2) +} + +func mintAlice(t *testing.T) string { + t.Helper() + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": aliceSub, "aud": "authenticated", "email": aliceEmail, + "exp": time.Now().Add(time.Hour).Unix(), + }).SignedString([]byte(stagingSecret)) + if err != nil { + t.Fatalf("mint: %v", err) + } + return s +} + +func stagingReachable() bool { + c := &http.Client{Timeout: 1500 * time.Millisecond} + resp, err := c.Get(stagingURL + "/healthz") + if err != nil { + return false + } + _ = resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + +// session stands up the real MCP stack in front of the staging API and returns a +// connected client session. +func session(t *testing.T) (*mcp.ClientSession, context.Context) { + t.Helper() + cfg := config.Config{ + JWTSecret: stagingSecret, JWTAud: "authenticated", + APIBaseURL: stagingURL, PublicURL: "http://mcp.test", Resource: "http://mcp.test/mcp", + RequireAuth: true, Enabled: true, RateLimitPerMin: 0, + } + server := mcp.NewServer(&mcp.Implementation{Name: "footics-mcp-test", Version: "test"}, nil) + Register(server, Deps{API: apiclient.New(cfg.APIBaseURL), RateLimitPerMin: 0}) + + streamable := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return server }, + &mcp.StreamableHTTPOptions{Stateless: true}, + ) + authed := sdkauth.RequireBearerToken(auth.NewVerifier(cfg).Verify, + &sdkauth.RequireBearerTokenOptions{ResourceMetadataURL: cfg.Resource})(streamable) + + ts := httptest.NewServer(authed) + t.Cleanup(ts.Close) + + hc := &http.Client{Transport: bearerRT{token: mintAlice(t), base: http.DefaultTransport}} + transport := &mcp.StreamableClientTransport{Endpoint: ts.URL, HTTPClient: hc, DisableStandaloneSSE: true} + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + t.Cleanup(cancel) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil) + sess, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = sess.Close() }) + return sess, ctx +} + +// call invokes a tool and returns its decoded (non-error) JSON payload. +func call(t *testing.T, sess *mcp.ClientSession, ctx context.Context, name string, args map[string]any) any { + t.Helper() + res, err := sess.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("%s: transport error: %v", name, err) + } + if len(res.Content) != 1 { + t.Fatalf("%s: want 1 content block, got %d", name, len(res.Content)) + } + tc, ok := res.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("%s: content not text: %T", name, res.Content[0]) + } + if res.IsError { + t.Fatalf("%s: tool error: %s", name, tc.Text) + } + var v any + if err := json.Unmarshal([]byte(tc.Text), &v); err != nil { + t.Fatalf("%s: bad JSON: %v\n%s", name, err, tc.Text) + } + return v +} + +func TestIntegrationReadTools(t *testing.T) { + if !stagingReachable() { + t.Skipf("staging API not reachable at %s — skipping", stagingURL) + } + sess, ctx := session(t) + + // tools/list — all 10 tools present. + lt, err := sess.ListTools(ctx, nil) + if err != nil { + t.Fatalf("list tools: %v", err) + } + names := map[string]bool{} + for _, tool := range lt.Tools { + names[tool.Name] = true + } + for _, want := range []string{"whoami", "list_matches", "get_match", "get_my_standing", "get_my_predictions", "get_leaderboard", "list_my_groups", "get_joker_status", "search", "submit_prediction"} { + if !names[want] { + t.Errorf("tools/list missing %q", want) + } + } + if len(lt.Tools) != 10 { + t.Errorf("tools/list count = %d, want 10", len(lt.Tools)) + } + + // whoami — identity straight from the JWT. + who := call(t, sess, ctx, "whoami", nil).(map[string]any) + if who["userId"] != aliceSub { + t.Errorf("whoami userId = %v", who["userId"]) + } + if who["email"] != aliceEmail { + t.Errorf("whoami email = %v", who["email"]) + } + + // list_matches — flat array; FRA-MAR present with alice's prediction. + matches := call(t, sess, ctx, "list_matches", map[string]any{"competition": "wc"}).([]any) + if len(matches) == 0 { + t.Fatal("list_matches empty") + } + fraMar := findMatch(matches, "FRA", "MAR") + if fraMar == nil { + t.Fatal("list_matches: FRA-MAR not found") + } + assertMatchShape(t, fraMar) + if fraMar["myPrediction"] == nil { + t.Error("FRA-MAR: alice's myPrediction should be present") + } + matchID, _ := fraMar["id"].(string) + + // get_match — detail carries an events array. + detail := call(t, sess, ctx, "get_match", map[string]any{"matchId": matchID}).(map[string]any) + assertMatchShape(t, detail) + if _, ok := detail["events"].([]any); !ok { + t.Errorf("get_match: events not an array: %T", detail["events"]) + } + + // get_my_standing — alice's standing for wc. + st := call(t, sess, ctx, "get_my_standing", map[string]any{"competition": "wc"}).(map[string]any) + for _, k := range []string{"userId", "username", "competition", "points", "exact", "rank", "rankOf"} { + if _, ok := st[k]; !ok { + t.Errorf("get_my_standing missing %q", k) + } + } + if st["userId"] != aliceSub || st["username"] != "alice" { + t.Errorf("get_my_standing identity = %v/%v", st["userId"], st["username"]) + } + + // get_my_predictions — prediction has {home,away,joker} and NO winnerTeamCode. + preds := call(t, sess, ctx, "get_my_predictions", map[string]any{"competition": "wc", "when": "all"}).([]any) + if len(preds) == 0 { + t.Fatal("get_my_predictions empty (alice has predictions in the seed)") + } + p0 := preds[0].(map[string]any) + for _, k := range []string{"matchId", "fixture", "kickoffAt", "status", "prediction", "result", "points"} { + if _, ok := p0[k]; !ok { + t.Errorf("prediction missing %q", k) + } + } + pred := p0["prediction"].(map[string]any) + if _, leaked := pred["winnerTeamCode"]; leaked { + t.Error("prediction leaked winnerTeamCode (must be dropped in the frozen shape)") + } + for _, k := range []string{"home", "away", "joker"} { + if _, ok := pred[k]; !ok { + t.Errorf("prediction.%s missing", k) + } + } + + // get_leaderboard — flat rows, alice flagged me:true. + board := call(t, sess, ctx, "get_leaderboard", map[string]any{"competition": "wc"}).([]any) + if len(board) == 0 { + t.Fatal("get_leaderboard empty") + } + var sawMe bool + for _, r := range board { + row := r.(map[string]any) + for _, k := range []string{"rank", "username", "points", "exact", "me"} { + if _, ok := row[k]; !ok { + t.Errorf("leaderboard row missing %q", k) + } + } + if row["me"] == true { + sawMe = true + } + } + if !sawMe { + t.Error("get_leaderboard: no row flagged me:true for alice") + } + + // list_my_groups — array (alice may have none → []). + if _, ok := call(t, sess, ctx, "list_my_groups", map[string]any{"competition": "wc"}).([]any); !ok { + t.Error("list_my_groups did not return an array") + } + + // get_joker_status — buckets; wc has a "group" bucket, quota 12. + jokers := call(t, sess, ctx, "get_joker_status", map[string]any{"competition": "wc"}).([]any) + if len(jokers) == 0 { + t.Fatal("get_joker_status empty") + } + var groupBucket map[string]any + for _, j := range jokers { + b := j.(map[string]any) + for _, k := range []string{"bucket", "label", "used", "quota", "remaining"} { + if _, ok := b[k]; !ok { + t.Errorf("joker bucket missing %q", k) + } + } + if b["bucket"] == "group" { + groupBucket = b + } + } + if groupBucket == nil || groupBucket["quota"].(float64) != 12 { + t.Errorf("get_joker_status: group bucket wrong: %v", groupBucket) + } + + // search — {matches,groups,users}; FRA-MAR surfaced by "fra". + sr := call(t, sess, ctx, "search", map[string]any{"query": "fra"}).(map[string]any) + for _, k := range []string{"matches", "groups", "users"} { + if _, ok := sr[k].([]any); !ok { + t.Errorf("search.%s not an array", k) + } + } + if len(sr["matches"].([]any)) == 0 { + t.Error("search 'fra' found no matches (expected FRA-MAR)") + } + + // submit_prediction — stub: writes OFF ⇒ tool error with the FR message. + res, err := sess.CallTool(ctx, &mcp.CallToolParams{Name: "submit_prediction", Arguments: map[string]any{"matchId": matchID, "homeScore": 1, "awayScore": 0}}) + if err != nil { + t.Fatalf("submit_prediction transport error: %v", err) + } + if !res.IsError { + t.Error("submit_prediction should be a tool error while writes are OFF") + } +} + +func findMatch(matches []any, home, away string) map[string]any { + for _, m := range matches { + mm := m.(map[string]any) + h, _ := mm["home"].(map[string]any) + a, _ := mm["away"].(map[string]any) + if h != nil && a != nil && h["code"] == home && a["code"] == away { + return mm + } + } + return nil +} + +func assertMatchShape(t *testing.T, m map[string]any) { + t.Helper() + for _, k := range []string{"id", "competition", "phase", "group", "status", "kickoffAt", "venue", "home", "away", "score", "live", "myPrediction"} { + if _, ok := m[k]; !ok { + t.Errorf("match missing key %q", k) + } + } + if m["competition"] != "wc" && m["competition"] != "friendlies" { + t.Errorf("match.competition = %v, want wc|friendlies", m["competition"]) + } +} diff --git a/go/internal/tools/ratelimit.go b/go/internal/tools/ratelimit.go new file mode 100644 index 0000000..08159bb --- /dev/null +++ b/go/internal/tools/ratelimit.go @@ -0,0 +1,72 @@ +package tools + +// ratelimit.go — per-user fixed-window limiter, ported from lib/rate-limit.ts. +// On the single long-lived VPS process this is finally a real global ceiling +// (the serverless TS server counted per-lambda). footics-api has its own limiter +// + Cloudflare WAF in front, so this is a courtesy cap, not the security control. + +import ( + "sync" + "time" +) + +const ( + rlWindow = time.Minute + rlMaxEntries = 10_000 +) + +type rlEntry struct { + startedAt time.Time + count int +} + +type rateLimiter struct { + perMin int + mu sync.Mutex + // ponytail: one global map + mutex. Fine at Footics' scale (a handful of + // calls/sec); shard by user hash only if this mutex ever shows up in a profile. + windows map[string]*rlEntry +} + +func newRateLimiter(perMin int) *rateLimiter { + return &rateLimiter{perMin: perMin, windows: make(map[string]*rlEntry)} +} + +// retryAfter returns 0 when the call is allowed, or the seconds to wait when the +// user's window is full (mirrors rateLimit() in the TS server). +func (rl *rateLimiter) retryAfter(userID string, now time.Time) int { + if rl.perMin <= 0 { + return 0 + } + rl.mu.Lock() + defer rl.mu.Unlock() + + w := rl.windows[userID] + if w == nil || now.Sub(w.startedAt) >= rlWindow { + if len(rl.windows) >= rlMaxEntries { + rl.prune(now) + } + rl.windows[userID] = &rlEntry{startedAt: now, count: 1} + return 0 + } + if w.count < rl.perMin { + w.count++ + return 0 + } + remaining := w.startedAt.Add(rlWindow).Sub(now).Seconds() + if remaining < 1 { + return 1 + } + return int(remaining) + 1 // ceil, matching Math.ceil in the TS server +} + +func (rl *rateLimiter) prune(now time.Time) { + for k, w := range rl.windows { + if now.Sub(w.startedAt) >= rlWindow { + delete(rl.windows, k) + } + } + if len(rl.windows) >= rlMaxEntries { + rl.windows = make(map[string]*rlEntry) + } +} diff --git a/go/internal/tools/schemas.go b/go/internal/tools/schemas.go new file mode 100644 index 0000000..9458c0b --- /dev/null +++ b/go/internal/tools/schemas.go @@ -0,0 +1,137 @@ +package tools + +// schemas.go — the FROZEN input schemas, fixed EXPLICITLY (not inferred from Go +// structs) so tools/list advertises the same params, enums, bounds, defaults and +// FR descriptions as the zod-derived TS schemas. The SDK applies schema defaults +// (competition→"wc", when→"all", joker→false) before validating and unmarshaling, +// so absent optional args resolve exactly as they did in the TS server. + +import ( + "encoding/json" + + "github.com/google/jsonschema-go/jsonschema" +) + +// typed argument structs (only used to unmarshal the defaults-applied arguments; +// the wire schema is the explicit one set on each Tool). +type ( + argsWhoami struct{} + argsListMatches struct { + Competition string `json:"competition"` + Status string `json:"status"` + Limit int `json:"limit"` + } + argsGetMatch struct { + MatchID string `json:"matchId"` + } + argsComp struct { + Competition string `json:"competition"` + } + argsPredictions struct { + Competition string `json:"competition"` + When string `json:"when"` + Limit int `json:"limit"` + } + argsLeaderboard struct { + Competition string `json:"competition"` + GroupID string `json:"groupId"` + Limit int `json:"limit"` + } + argsSearch struct { + Query string `json:"query"` + } + argsSubmit struct { + MatchID string `json:"matchId"` + HomeScore int `json:"homeScore"` + AwayScore int `json:"awayScore"` + Joker bool `json:"joker"` + WinnerTeamCode string `json:"winnerTeamCode"` + } +) + +func f64(v float64) *float64 { return &v } +func iptr(v int) *int { return &v } + +func rawDefault(v any) json.RawMessage { + b, _ := json.Marshal(v) + return b +} + +func object(props map[string]*jsonschema.Schema, required ...string) *jsonschema.Schema { + return &jsonschema.Schema{Type: "object", Properties: props, Required: required} +} + +// competitionSchema is the shared `competition` param (enum + default wc). +func competitionSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "string", + Enum: []any{"wc", "friendlies"}, + Default: rawDefault("wc"), + Description: `Compétition : "wc" (Coupe du monde) ou "friendlies" (amicaux). Défaut: wc.`, + } +} + +func schemaWhoami() *jsonschema.Schema { return object(map[string]*jsonschema.Schema{}) } + +func schemaListMatches() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{ + "competition": competitionSchema(), + "status": { + Type: "string", + Enum: []any{"scheduled", "live", "finished", "postponed", "cancelled"}, + Description: "Filtre de statut optionnel.", + }, + "limit": {Type: "integer", Minimum: f64(1), Maximum: f64(200), Description: "Nombre max de matchs (défaut 200)."}, + }) +} + +func schemaGetMatch() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{ + "matchId": {Type: "string", MinLength: iptr(1), Description: "L'id (uuid) du match."}, + }, "matchId") +} + +func schemaComp() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{"competition": competitionSchema()}) +} + +func schemaPredictions() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{ + "competition": competitionSchema(), + "when": { + Type: "string", + Enum: []any{"upcoming", "past", "all"}, + Default: rawDefault("all"), + }, + "limit": {Type: "integer", Minimum: f64(1), Maximum: f64(200)}, + }) +} + +func schemaLeaderboard() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{ + "competition": competitionSchema(), + "groupId": {Type: "string", Description: "Id d'un groupe dont je suis membre (sinon classement général)."}, + "limit": {Type: "integer", Minimum: f64(1), Maximum: f64(200)}, + }) +} + +func schemaSearch() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{ + "query": {Type: "string", MinLength: iptr(1), Description: "Texte recherché."}, + }, "query") +} + +func schemaSubmit() *jsonschema.Schema { + return object(map[string]*jsonschema.Schema{ + "matchId": {Type: "string", MinLength: iptr(1), Description: "L'id (uuid) du match — voir list_matches/search."}, + "homeScore": {Type: "integer", Minimum: f64(0), Maximum: f64(20), Description: "Score prédit de l'équipe à domicile (0-20)."}, + "awayScore": {Type: "integer", Minimum: f64(0), Maximum: f64(20), Description: "Score prédit de l'équipe à l'extérieur (0-20)."}, + "joker": {Type: "boolean", Default: rawDefault(false), Description: "Appliquer un joker (double les points). Défaut: false."}, + "winnerTeamCode": { + Type: "string", + MinLength: iptr(2), + MaxLength: iptr(3), + Description: "Match à élimination directe + nul prédit UNIQUEMENT : code FIFA-3 de l'équipe qui se qualifie (pour le +1). Doit être l'une des 2 équipes. Ignoré sur un score décisif ou en phase de poules.", + }, + }, "matchId", "homeScore", "awayScore") +} diff --git a/go/internal/tools/shapes.go b/go/internal/tools/shapes.go new file mode 100644 index 0000000..9d4821b --- /dev/null +++ b/go/internal/tools/shapes.go @@ -0,0 +1,328 @@ +package tools + +// shapes.go — the FROZEN MCP output shapes and the projections from the /v1 +// view-model into them. These structs (field names, order, and null semantics) +// are the public contract that existing MCP connectors depend on; they mirror +// lib/tools.ts + lib/queries.ts of the TS server exactly. +// +// Null semantics: a "T | null" frozen field is a pointer WITHOUT omitempty, so a +// nil marshals to `null` (present key), matching JSON.stringify of the TS +// view-model. Slices are pre-allocated non-nil so an empty result marshals to +// `[]`, never `null`. + +import ( + "time" + + "github.com/footics/mcp-server/internal/apiclient" +) + +/* ── whoami ────────────────────────────────────────────────────────────────── */ + +type outWhoami struct { + UserID string `json:"userId"` + Email *string `json:"email"` +} + +/* ── match (list_matches / get_match) ──────────────────────────────────────── */ + +type outTeam struct { + Code string `json:"code"` + Name string `json:"name"` +} + +type outScore struct { + Home int `json:"home"` + Away int `json:"away"` +} + +type outLive struct { + Period string `json:"period"` + Minute *int `json:"minute"` + Running bool `json:"running"` +} + +type outMyPred struct { + Home int `json:"home"` + Away int `json:"away"` + Joker bool `json:"joker"` + Points *int `json:"points"` +} + +type outMatch struct { + ID string `json:"id"` + Competition string `json:"competition"` + Phase string `json:"phase"` + Group *string `json:"group"` + Status string `json:"status"` + KickoffAt string `json:"kickoffAt"` + Venue *string `json:"venue"` + Home outTeam `json:"home"` + Away outTeam `json:"away"` + Score *outScore `json:"score"` + Live *outLive `json:"live"` + MyPrediction *outMyPred `json:"myPrediction"` +} + +type outEvent struct { + Type string `json:"type"` + Period string `json:"period"` + Minute int `json:"minute"` + MinutePlus *int `json:"minutePlus"` + TeamCode *string `json:"teamCode"` + Player *string `json:"player"` + Detail *string `json:"detail"` +} + +// outMatchDetail is outMatch + events (events appended last, mirroring the TS +// spread `{...base, events}`). +type outMatchDetail struct { + outMatch + Events []outEvent `json:"events"` +} + +func projectMatch(m apiclient.Match) outMatch { + // score: present only for live|finished. TS reads the persisted home/away + // score column: Score for finished, LiveScore for live. + var score *outScore + switch m.Status { + case "finished": + score = fromScore(m.Score) + case "live": + score = fromScore(m.LiveScore) + } + + var live *outLive + if m.Status == "live" && m.Live != nil { + live = &outLive{Period: m.Live.Period, Minute: m.Live.DisplayMinute, Running: m.Live.Running} + } + + var pred *outMyPred + if m.Mine != nil { + joker := false + if m.JokerApplied != nil { + joker = *m.JokerApplied + } + pred = &outMyPred{Home: m.Mine.H, Away: m.Mine.A, Joker: joker, Points: m.Points} + } + + return outMatch{ + ID: m.ID, + Competition: m.Competition.Kind, + Phase: m.Phase, + Group: m.Group, + Status: m.Status, + KickoffAt: toISO(m.KickoffAt), + Venue: nilIfEmpty(m.Venue), + Home: outTeam{Code: m.Home.Code, Name: m.Home.Name}, + Away: outTeam{Code: m.Away.Code, Name: m.Away.Name}, + Score: score, + Live: live, + MyPrediction: pred, + } +} + +func projectMatchDetail(d apiclient.MatchDetail) outMatchDetail { + base := projectMatch(d.Match) + events := make([]outEvent, 0, len(d.Events)) // [] not null + if base.Status != "scheduled" { // TS: [] before a match starts + for _, e := range d.Events { + events = append(events, outEvent{ + Type: e.Type, Period: e.Period, Minute: e.Minute, + MinutePlus: e.MinutePlus, TeamCode: e.TeamCode, Player: e.Player, Detail: e.Detail, + }) + } + } + return outMatchDetail{outMatch: base, Events: events} +} + +/* ── get_my_standing ───────────────────────────────────────────────────────── */ + +type outStanding struct { + UserID string `json:"userId"` + Username string `json:"username"` + Competition string `json:"competition"` + Points int `json:"points"` + Exact int `json:"exact"` + Rank int `json:"rank"` + RankOf int `json:"rankOf"` +} + +func projectStanding(me apiclient.Me, comp string) outStanding { + return outStanding{ + UserID: me.ID, Username: me.Username, Competition: comp, + Points: me.Points, Exact: me.Exact, Rank: me.Rank, RankOf: me.RankOf, + } +} + +/* ── get_my_predictions ────────────────────────────────────────────────────── */ + +type outPredScore struct { + Home int `json:"home"` + Away int `json:"away"` + Joker bool `json:"joker"` +} + +type outPrediction struct { + MatchID string `json:"matchId"` + Fixture string `json:"fixture"` + KickoffAt string `json:"kickoffAt"` + Status string `json:"status"` + Prediction outPredScore `json:"prediction"` + Result *outScore `json:"result"` + Points *int `json:"points"` +} + +func projectPredictions(ps []apiclient.Prediction) []outPrediction { + out := make([]outPrediction, 0, len(ps)) + for _, p := range ps { + var result *outScore + if p.Result != nil { + result = &outScore{Home: p.Result.Home, Away: p.Result.Away} + } + out = append(out, outPrediction{ + MatchID: p.MatchID, + Fixture: p.Fixture, + KickoffAt: toISO(p.KickoffAt), + Status: p.Status, + Prediction: outPredScore{Home: p.Prediction.Home, Away: p.Prediction.Away, Joker: p.Prediction.Joker}, + Result: result, + Points: p.Points, + }) + } + return out +} + +/* ── get_leaderboard ───────────────────────────────────────────────────────── */ + +type outLeaderRow struct { + Rank int `json:"rank"` + Username string `json:"username"` + Points int `json:"points"` + Exact int `json:"exact"` + Me bool `json:"me"` +} + +func projectLeaderboard(rows []apiclient.LeaderRow) []outLeaderRow { + out := make([]outLeaderRow, 0, len(rows)) + for _, r := range rows { + out = append(out, outLeaderRow{Rank: r.Rank, Username: r.Username, Points: r.Points, Exact: r.Exact, Me: r.Me}) + } + return out +} + +/* ── list_my_groups ────────────────────────────────────────────────────────── */ + +type outGroup struct { + ID string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Members int `json:"members"` + MyRank int `json:"myRank"` + MyPoints int `json:"myPoints"` + Owner bool `json:"owner"` +} + +func projectGroups(gs []apiclient.Group) []outGroup { + out := make([]outGroup, 0, len(gs)) + for _, g := range gs { + out = append(out, outGroup{ + ID: g.ID, Name: g.Name, Code: g.Code, Members: g.Members, + MyRank: g.MyRank, MyPoints: g.MyPoints, Owner: g.Owner != nil, + }) + } + return out +} + +/* ── get_joker_status ──────────────────────────────────────────────────────── */ + +type outJoker struct { + Bucket string `json:"bucket"` + Label string `json:"label"` + Used int `json:"used"` + Quota int `json:"quota"` + Remaining int `json:"remaining"` +} + +func projectJokers(js []apiclient.Joker) []outJoker { + out := make([]outJoker, 0, len(js)) + for _, j := range js { + out = append(out, outJoker{Bucket: j.Bucket, Label: j.Label, Used: j.Used, Quota: j.Quota, Remaining: j.Remaining}) + } + return out +} + +/* ── search ────────────────────────────────────────────────────────────────── */ + +type outSearchMatch struct { + ID string `json:"id"` + Fixture string `json:"fixture"` + KickoffAt string `json:"kickoffAt"` +} + +type outSearchGroup struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type outSearchUser struct { + ID string `json:"id"` + Username string `json:"username"` + Me bool `json:"me"` +} + +type outSearch struct { + Matches []outSearchMatch `json:"matches"` + Groups []outSearchGroup `json:"groups"` + Users []outSearchUser `json:"users"` +} + +func projectSearch(res apiclient.SearchResults) outSearch { + out := outSearch{ + Matches: make([]outSearchMatch, 0, len(res.Matches)), + Groups: make([]outSearchGroup, 0, len(res.Groups)), + Users: make([]outSearchUser, 0, len(res.Users)), + } + for _, m := range res.Matches { + out.Matches = append(out.Matches, outSearchMatch{ID: m.ID, Fixture: m.Fixture, KickoffAt: toISO(m.KickoffAt)}) + } + for _, g := range res.Groups { + out.Groups = append(out.Groups, outSearchGroup{ID: g.ID, Name: g.Name}) + } + for _, u := range res.Users { + out.Users = append(out.Users, outSearchUser{ID: u.ID, Username: u.Username, Me: u.Me}) + } + return out +} + +/* ── helpers ───────────────────────────────────────────────────────────────── */ + +func fromScore(s *apiclient.Score) *outScore { + if s == nil { + return nil + } + return &outScore{Home: s.H, Away: s.A} +} + +// nilIfEmpty maps the API's empty-string venue back to null, reproducing the TS +// `venue ?? null` for an absent venue (a real venue is always a non-empty name). +// ponytail: this collapses a genuinely-empty venue to null too — impossible in +// the data, and the frozen contract's type is `string | null`. +func nilIfEmpty(s string) *string { + if s == "" { + return nil + } + return &s +} + +// toISO normalises a /v1 timestamp to the exact JS `Date.toISOString()` form +// (UTC, always 3 fractional digits, `Z`). /v1/matches already emits millis, but +// /v1/predictions and /v1/search use RFC3339 without them — the frozen contract +// (built from toISOString) always has `.000`. Unparseable input is passed +// through untouched rather than dropped. +func toISO(s string) string { + t, err := time.Parse(time.RFC3339, s) // lenient: also accepts a fractional part + if err != nil { + return s + } + return t.UTC().Format("2006-01-02T15:04:05.000Z07:00") +} diff --git a/go/internal/tools/shapes_test.go b/go/internal/tools/shapes_test.go new file mode 100644 index 0000000..de9a557 --- /dev/null +++ b/go/internal/tools/shapes_test.go @@ -0,0 +1,160 @@ +package tools + +// shapes_test.go — asserts each projection produces the EXACT frozen JSON shape +// (field names, order, null semantics) the TS server emitted. Compact JSON is +// compared byte-for-byte; struct field order is the declaration order. + +import ( + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/footics/mcp-server/internal/apiclient" +) + +func sp(s string) *string { return &s } +func ip(i int) *int { return &i } +func bp(b bool) *bool { return &b } + +func eq(t *testing.T, name, got, want string) { + t.Helper() + if got != want { + t.Errorf("%s:\n got: %s\nwant: %s", name, got, want) + } +} + +func TestProjectMatchFinished(t *testing.T) { + m := apiclient.Match{ + ID: "m1", Phase: "group", Status: "finished", KickoffAt: "2026-07-04T15:00:00.000Z", Venue: "", + Home: apiclient.Team{Code: "FRA", Name: "France"}, Away: apiclient.Team{Code: "MAR", Name: "Maroc"}, + Score: &apiclient.Score{H: 2, A: 1}, Mine: &apiclient.Score{H: 2, A: 1}, JokerApplied: bp(false), Points: ip(3), + } + m.Competition.Kind = "wc" + eq(t, "finished", marshalCompact(projectMatch(m)), + `{"id":"m1","competition":"wc","phase":"group","group":null,"status":"finished","kickoffAt":"2026-07-04T15:00:00.000Z","venue":null,"home":{"code":"FRA","name":"France"},"away":{"code":"MAR","name":"Maroc"},"score":{"home":2,"away":1},"live":null,"myPrediction":{"home":2,"away":1,"joker":false,"points":3}}`) +} + +func TestProjectMatchLive(t *testing.T) { + m := apiclient.Match{ + ID: "m2", Phase: "group", Group: sp("A"), Status: "live", KickoffAt: "2026-07-04T18:00:00Z", Venue: "MetLife Stadium", + Home: apiclient.Team{Code: "BRA", Name: "Brésil"}, Away: apiclient.Team{Code: "ARG", Name: "Argentine"}, + LiveScore: &apiclient.Score{H: 1, A: 0}, + Live: &apiclient.LiveClock{Period: "second_half", DisplayMinute: ip(52), Running: true}, + Mine: &apiclient.Score{H: 1, A: 1}, JokerApplied: bp(true), Points: nil, + } + m.Competition.Kind = "wc" + // kickoffAt normalised to millis; score from liveScore; myPrediction.points null. + eq(t, "live", marshalCompact(projectMatch(m)), + `{"id":"m2","competition":"wc","phase":"group","group":"A","status":"live","kickoffAt":"2026-07-04T18:00:00.000Z","venue":"MetLife Stadium","home":{"code":"BRA","name":"Brésil"},"away":{"code":"ARG","name":"Argentine"},"score":{"home":1,"away":0},"live":{"period":"second_half","minute":52,"running":true},"myPrediction":{"home":1,"away":1,"joker":true,"points":null}}`) +} + +func TestProjectMatchScheduledNoPred(t *testing.T) { + m := apiclient.Match{ID: "m3", Phase: "group", Status: "scheduled", KickoffAt: "2026-07-04T20:00:00.000Z", + Home: apiclient.Team{Code: "ESP", Name: "Espagne"}, Away: apiclient.Team{Code: "GER", Name: "Allemagne"}} + m.Competition.Kind = "wc" + eq(t, "scheduled", marshalCompact(projectMatch(m)), + `{"id":"m3","competition":"wc","phase":"group","group":null,"status":"scheduled","kickoffAt":"2026-07-04T20:00:00.000Z","venue":null,"home":{"code":"ESP","name":"Espagne"},"away":{"code":"GER","name":"Allemagne"},"score":null,"live":null,"myPrediction":null}`) +} + +func TestProjectMatchDetailScheduledForcesEmptyEvents(t *testing.T) { + d := apiclient.MatchDetail{ + Match: apiclient.Match{ID: "m3", Phase: "group", Status: "scheduled", KickoffAt: "2026-07-04T20:00:00.000Z", Home: apiclient.Team{Code: "ESP", Name: "Espagne"}, Away: apiclient.Team{Code: "GER", Name: "Allemagne"}}, + Events: []apiclient.Event{{Type: "goal", Period: "first_half", Minute: 5}}, // must be dropped: scheduled ⇒ [] + } + d.Competition.Kind = "wc" + got := marshalCompact(projectMatchDetail(d)) + want := `{"id":"m3","competition":"wc","phase":"group","group":null,"status":"scheduled","kickoffAt":"2026-07-04T20:00:00.000Z","venue":null,"home":{"code":"ESP","name":"Espagne"},"away":{"code":"GER","name":"Allemagne"},"score":null,"live":null,"myPrediction":null,"events":[]}` + eq(t, "detail-scheduled", got, want) +} + +func TestProjectMatchDetailFinishedDropsAssist(t *testing.T) { + d := apiclient.MatchDetail{ + Match: apiclient.Match{ID: "m1", Phase: "group", Status: "finished", KickoffAt: "2026-07-04T15:00:00.000Z", Home: apiclient.Team{Code: "FRA", Name: "France"}, Away: apiclient.Team{Code: "MAR", Name: "Maroc"}, Score: &apiclient.Score{H: 2, A: 1}}, + Events: []apiclient.Event{{Type: "goal", Period: "first_half", Minute: 23, TeamCode: sp("FRA"), Player: sp("Mbappé")}}, + } + d.Competition.Kind = "wc" + got := marshalCompact(projectMatchDetail(d)) + // EventJson has no `assist`; minutePlus/detail present as null. + want := `{"id":"m1","competition":"wc","phase":"group","group":null,"status":"finished","kickoffAt":"2026-07-04T15:00:00.000Z","venue":null,"home":{"code":"FRA","name":"France"},"away":{"code":"MAR","name":"Maroc"},"score":{"home":2,"away":1},"live":null,"myPrediction":null,"events":[{"type":"goal","period":"first_half","minute":23,"minutePlus":null,"teamCode":"FRA","player":"Mbappé","detail":null}]}` + eq(t, "detail-finished", got, want) +} + +func TestProjectPredictions(t *testing.T) { + ps := []apiclient.Prediction{ + {MatchID: "m1", Fixture: "FRA-MAR", KickoffAt: "2026-07-04T15:00:00Z", Status: "finished", + Prediction: struct { + Home int `json:"home"` + Away int `json:"away"` + Joker bool `json:"joker"` + }{2, 1, false}, + Result: &struct { + Home int `json:"home"` + Away int `json:"away"` + }{2, 1}, Points: ip(3)}, + {MatchID: "m2", Fixture: "BRA-ARG", KickoffAt: "2026-07-04T18:00:00Z", Status: "scheduled", + Prediction: struct { + Home int `json:"home"` + Away int `json:"away"` + Joker bool `json:"joker"` + }{3, 0, false}}, + } + // prediction has NO winnerTeamCode; result/points present-null when absent; kickoffAt normalised. + want := `[{"matchId":"m1","fixture":"FRA-MAR","kickoffAt":"2026-07-04T15:00:00.000Z","status":"finished","prediction":{"home":2,"away":1,"joker":false},"result":{"home":2,"away":1},"points":3},{"matchId":"m2","fixture":"BRA-ARG","kickoffAt":"2026-07-04T18:00:00.000Z","status":"scheduled","prediction":{"home":3,"away":0,"joker":false},"result":null,"points":null}]` + eq(t, "predictions", marshalCompact(projectPredictions(ps)), want) +} + +func TestProjectGroupsOwnerFlag(t *testing.T) { + gs := []apiclient.Group{ + {ID: "g1", Name: "Les Potes", Code: "ABC123", Members: 5, MyRank: 2, MyPoints: 12, Owner: sp("me")}, + {ID: "g2", Name: "Boulot", Code: "XYZ", Members: 3, MyRank: 3, MyPoints: 0, Owner: nil}, + } + want := `[{"id":"g1","name":"Les Potes","code":"ABC123","members":5,"myRank":2,"myPoints":12,"owner":true},{"id":"g2","name":"Boulot","code":"XYZ","members":3,"myRank":3,"myPoints":0,"owner":false}]` + eq(t, "groups", marshalCompact(projectGroups(gs)), want) +} + +func TestProjectSearchEmptyIsArrays(t *testing.T) { + // empty categories marshal to [], not null. + eq(t, "search-empty", marshalCompact(projectSearch(apiclient.SearchResults{})), `{"matches":[],"groups":[],"users":[]}`) +} + +func TestToISO(t *testing.T) { + cases := map[string]string{ + "2026-07-04T18:00:00Z": "2026-07-04T18:00:00.000Z", + "2026-07-04T15:00:00.000Z": "2026-07-04T15:00:00.000Z", + "2026-07-04T15:00:00.42Z": "2026-07-04T15:00:00.420Z", + "not-a-date": "not-a-date", // unparseable passes through + } + for in, want := range cases { + if got := toISO(in); got != want { + t.Errorf("toISO(%q) = %q, want %q", in, got, want) + } + } +} + +func TestJSONEnvelopes(t *testing.T) { + if txt := text(t, jsonErr("Match introuvable.")); txt != `{"error":"Match introuvable."}` { + t.Errorf("jsonErr text = %q", txt) + } + if !jsonErr("x").IsError { + t.Error("jsonErr must set IsError") + } + // jsonOK is pretty-printed (2-space indent) and does not HTML-escape. + if txt := text(t, jsonOK(map[string]string{"a": "&"})); txt != "{\n \"a\": \"&\"\n}" { + t.Errorf("jsonOK text = %q", txt) + } + if jsonOK(map[string]string{}).IsError { + t.Error("jsonOK must not set IsError") + } +} + +func text(t *testing.T, r *mcp.CallToolResult) string { + t.Helper() + if len(r.Content) != 1 { + t.Fatalf("want 1 content block, got %d", len(r.Content)) + } + tc, ok := r.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("content is not TextContent: %T", r.Content[0]) + } + return tc.Text +} diff --git a/go/internal/tools/tools.go b/go/internal/tools/tools.go new file mode 100644 index 0000000..f8cb311 --- /dev/null +++ b/go/internal/tools/tools.go @@ -0,0 +1,364 @@ +// Package tools registers the Footics MCP tools on a go-sdk server. Each read +// tool is one authenticated /v1 call (Bearer relayed) projected into the frozen +// output shape (shapes.go). whoami is served from the JWT alone. submit_prediction +// is a stub until footics-api ships POST /v1/predictions (M4). +// +// Output envelope (frozen): success = one text block of pretty-printed JSON +// (2-space indent, HTML-escaping OFF, matching JSON.stringify(data,null,2)); +// error = one text block of {"error":"…"} with IsError set. +package tools + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "time" + + sdkauth "github.com/modelcontextprotocol/go-sdk/auth" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/footics/mcp-server/internal/apiclient" + "github.com/footics/mcp-server/internal/auth" +) + +// Deps are the tool layer's collaborators. +type Deps struct { + API *apiclient.Client + EnableWrites bool + TestUserID string // identity fallback when MCP_REQUIRE_AUTH=false (no TokenInfo) + RateLimitPerMin int +} + +type toolServer struct { + api *apiclient.Client + enableWrites bool + testUserID string + rl *rateLimiter + rlPerMin int +} + +// Register adds the 10 tools (9 reads + the submit_prediction stub) to server. +func Register(server *mcp.Server, d Deps) { + s := &toolServer{ + api: d.API, + enableWrites: d.EnableWrites, + testUserID: d.TestUserID, + rl: newRateLimiter(d.RateLimitPerMin), + rlPerMin: d.RateLimitPerMin, + } + + mcp.AddTool(server, &mcp.Tool{ + Name: "whoami", + Title: "Qui suis-je", + Description: "Renvoie l'identité Footics de l'utilisateur connecté (id, email). Utile pour vérifier la connexion.", + InputSchema: schemaWhoami(), + }, s.whoami) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_matches", + Title: "Lister les matchs", + Description: "Liste les matchs d'une compétition (avec mon prono, le statut, le score live/final). Filtrable par statut. Pour voir le calendrier, les matchs à venir, en cours ou terminés.", + InputSchema: schemaListMatches(), + }, s.listMatches) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_match", + Title: "Détail d'un match", + Description: "Renvoie un match par son id : équipes, coup d'envoi, statut, score, timeline des buts/cartons (si commencé) et mon prono.", + InputSchema: schemaGetMatch(), + }, s.getMatch) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_my_standing", + Title: "Mon classement", + Description: "Mes points, scores exacts, mon rang et le nombre total de joueurs pour une compétition.", + InputSchema: schemaComp(), + }, s.getMyStanding) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_my_predictions", + Title: "Mes pronos", + Description: "Mes pronostics pour une compétition. `when` = upcoming (à venir), past (terminés) ou all (tous, défaut).", + InputSchema: schemaPredictions(), + }, s.getMyPredictions) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_leaderboard", + Title: "Classement", + Description: "Classement général d'une compétition, ou d'un de mes groupes (via groupId). Trié par points puis scores exacts.", + InputSchema: schemaLeaderboard(), + }, s.getLeaderboard) + + mcp.AddTool(server, &mcp.Tool{ + Name: "list_my_groups", + Title: "Mes groupes", + Description: "Les groupes dont je suis membre, avec mon rang et mes points dans chacun pour la compétition donnée.", + InputSchema: schemaComp(), + }, s.listMyGroups) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_joker_status", + Title: "Mes jokers", + Description: "Mon stock de jokers par bucket (poules, 8es, …) pour une compétition : utilisés / quota / restants. Le joker double les points d'un match.", + InputSchema: schemaComp(), + }, s.getJokerStatus) + + mcp.AddTool(server, &mcp.Tool{ + Name: "search", + Title: "Rechercher", + Description: "Recherche transverse : matchs (par équipe/poule), mes groupes (par nom), joueurs (par pseudo).", + InputSchema: schemaSearch(), + }, s.search) + + // submit_prediction always appears (prod runs with writes ON), but the real + // write path lands with footics-api POST /v1/predictions (M4). Until then it + // is a stub — see submitPrediction. + mcp.AddTool(server, &mcp.Tool{ + Name: "submit_prediction", + Title: "Poser un prono", + Description: "Pose ou modifie MON pronostic sur un match (scores 0-20, joker optionnel). Refusé si le coup d'envoi est passé ou si je n'ai plus de joker pour ce bucket. Sur un match à élimination directe : le prono porte sur le score à la fin du temps réglementaire (90'), et si tu prédis un NUL, précise winnerTeamCode (l'équipe qui se qualifie) pour le +1 bonus. Confirme toujours avec l'utilisateur avant d'écrire.", + InputSchema: schemaSubmit(), + }, s.submitPrediction) +} + +/* ── identity + gate (auth + rate-limit) ───────────────────────────────────── */ + +type identity struct { + userID string + token string // the caller's Bearer, relayed to footics-api + email *string +} + +// gate resolves the caller's identity (from the verified JWT, or the TestUserID +// fallback in authless mode) and applies the per-user rate limit. A non-nil +// result is the error to return from the tool. +func (s *toolServer) gate(ctx context.Context) (identity, *mcp.CallToolResult) { + var id identity + if ti := sdkauth.TokenInfoFromContext(ctx); ti != nil { + id.userID = ti.UserID + if e, _ := ti.Extra[auth.ExtraEmail].(string); e != "" { + id.email = &e + } + if t, _ := ti.Extra[auth.ExtraToken].(string); t != "" { + id.token = t + } + } else if s.testUserID != "" { + id.userID = s.testUserID + } + + if id.userID == "" { + return id, jsonErr("Non authentifié.") + } + if retry := s.rl.retryAfter(id.userID, time.Now()); retry > 0 { + return id, jsonErr(fmt.Sprintf("Limite de débit atteinte (%d appels/min) — attends ~%ds avant de réessayer.", s.rlPerMin, retry)) + } + return id, nil +} + +/* ── read tools ────────────────────────────────────────────────────────────── */ + +func (s *toolServer) whoami(ctx context.Context, _ *mcp.CallToolRequest, _ argsWhoami) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + return jsonOK(outWhoami{UserID: id.userID, Email: id.email}), nil, nil +} + +func (s *toolServer) listMatches(ctx context.Context, _ *mcp.CallToolRequest, a argsListMatches) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + ms, err := s.api.Matches(ctx, id.token, comp(a.Competition), a.Status, clampLimit(a.Limit, 200, 200)) + if err != nil { + return apiErr("list_matches", err), nil, nil + } + out := make([]outMatch, 0, len(ms)) + for _, m := range ms { + out = append(out, projectMatch(m)) + } + return jsonOK(out), nil, nil +} + +func (s *toolServer) getMatch(ctx context.Context, _ *mcp.CallToolRequest, a argsGetMatch) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + m, found, err := s.api.Match(ctx, id.token, a.MatchID) + if err != nil { + return apiErr("get_match", err), nil, nil + } + if !found { + return jsonErr("Match introuvable."), nil, nil + } + return jsonOK(projectMatchDetail(*m)), nil, nil +} + +func (s *toolServer) getMyStanding(ctx context.Context, _ *mcp.CallToolRequest, a argsComp) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + c := comp(a.Competition) + me, found, err := s.api.Me(ctx, id.token, c) + if err != nil { + return apiErr("get_my_standing", err), nil, nil + } + if !found { + return jsonErr("Profil introuvable."), nil, nil + } + return jsonOK(projectStanding(*me, c)), nil, nil +} + +func (s *toolServer) getMyPredictions(ctx context.Context, _ *mcp.CallToolRequest, a argsPredictions) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + when := a.When + if when == "" { + when = "all" + } + ps, err := s.api.Predictions(ctx, id.token, comp(a.Competition), when, clampLimit(a.Limit, 100, 200)) + if err != nil { + return apiErr("get_my_predictions", err), nil, nil + } + return jsonOK(projectPredictions(ps)), nil, nil +} + +func (s *toolServer) getLeaderboard(ctx context.Context, _ *mcp.CallToolRequest, a argsLeaderboard) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + rows, err := s.api.Leaderboard(ctx, id.token, comp(a.Competition), a.GroupID, clampLimit(a.Limit, 50, 200)) + if err != nil { + return apiErr("get_leaderboard", err), nil, nil + } + return jsonOK(projectLeaderboard(rows)), nil, nil +} + +func (s *toolServer) listMyGroups(ctx context.Context, _ *mcp.CallToolRequest, a argsComp) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + gs, err := s.api.Groups(ctx, id.token, comp(a.Competition)) + if err != nil { + return apiErr("list_my_groups", err), nil, nil + } + return jsonOK(projectGroups(gs)), nil, nil +} + +func (s *toolServer) getJokerStatus(ctx context.Context, _ *mcp.CallToolRequest, a argsComp) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + js, err := s.api.Jokers(ctx, id.token, comp(a.Competition)) + if err != nil { + return apiErr("get_joker_status", err), nil, nil + } + return jsonOK(projectJokers(js)), nil, nil +} + +func (s *toolServer) search(ctx context.Context, _ *mcp.CallToolRequest, a argsSearch) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { + return errRes, nil, nil + } + res, err := s.api.Search(ctx, id.token, a.Query) + if err != nil { + return apiErr("search", err), nil, nil + } + return jsonOK(projectSearch(res)), nil, nil +} + +/* ── write tool (stub until M4) ────────────────────────────────────────────── */ + +func (s *toolServer) submitPrediction(ctx context.Context, _ *mcp.CallToolRequest, _ argsSubmit) (*mcp.CallToolResult, any, error) { + if _, errRes := s.gate(ctx); errRes != nil { + return errRes, nil, nil + } + if !s.enableWrites { + return jsonErr("L'écriture de pronos via MCP n'est pas encore disponible sur cette instance (MCP_ENABLE_WRITES=false)."), nil, nil + } + // Writes enabled, but the write path (footics-api POST /v1/predictions) ships + // with M4 and is not wired here yet. + return jsonErr("L'écriture de pronos via MCP arrivera avec l'API /v1 POST (M4) — non encore câblée sur cette instance."), nil, nil +} + +/* ── helpers ───────────────────────────────────────────────────────────────── */ + +// comp defaults an empty competition to "wc" (the schema default already fills +// it; this is belt-and-braces for authless/direct calls). +func comp(c string) string { + if c == "" { + return "wc" + } + return c +} + +// clampLimit mirrors Math.min(Math.max(limit ?? def, 1), max): an absent (0) +// limit uses def, otherwise it is clamped to [1, max]. +func clampLimit(v, def, max int) int { + if v <= 0 { + v = def + } + if v < 1 { + v = 1 + } + if v > max { + v = max + } + return v +} + +func jsonOK(data any) *mcp.CallToolResult { + text, err := marshalPretty(data) + if err != nil { + return jsonErr("Erreur interne de sérialisation.") + } + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}} +} + +func jsonErr(msg string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: marshalCompact(map[string]string{"error": msg})}}, + IsError: true, + } +} + +// apiErr logs the underlying /v1 failure and returns a generic FR tool error (the +// frozen contract never surfaced API internals; it hit the DB directly). +func apiErr(tool string, err error) *mcp.CallToolResult { + slog.Error("[mcp] /v1 call failed", "tool", tool, "err", err) + return jsonErr("Erreur lors de l'appel à l'API Footics. Réessaie plus tard.") +} + +// marshalPretty reproduces JSON.stringify(data, null, 2): 2-space indent with +// HTML escaping OFF (JS does not escape < > &). +func marshalPretty(v any) (string, error) { + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return "", err + } + return strings.TrimRight(b.String(), "\n"), nil +} + +// marshalCompact reproduces JSON.stringify(data) for the error envelope. +func marshalCompact(v any) string { + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) + return strings.TrimRight(b.String(), "\n") +} From d7d05a55fca181b251ede1d694e2eb111c3b7fc1 Mon Sep 17 00:00:00 2001 From: "Tom C." Date: Tue, 7 Jul 2026 18:26:48 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20MCP=20100%=20Go=20=E2=80=94=20promo?= =?UTF-8?q?tion=20de=20go-rewrite=20en=20main=20+=20submit=5Fprediction=20?= =?UTF-8?q?c=C3=A2bl=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le repo était resté sur l'implémentation Next.js alors que la PROD tourne un binaire Go (réécriture de la nuit du cutover, jamais promue). Ce PR : - promeut le serveur Go (go/ → racine) : cmd/mcp + internal/{auth,apiclient,tools,config}, client authentifié de l'API /v1 (JWKS/HS256, OAuth PRM, rate-limit 30/min, 10 outils) ; - supprime toute l'implémentation Next.js (app/, db/, lib/, components/…) — plus de connexion DB ni de service-role dans le MCP, l'API /v1 est la seule frontière ; - CÂBLE submit_prediction (était un stub) sur footics-api POST /v1/predictions : l'API reste le point d'écriture unique (verrou 90', quota joker, qualifié KO) et son message FR est renvoyé verbatim ; test unitaire httptest du forward + passthrough d'erreur ; - Dockerfile multi-stage (distroless) + deploy/compose.dokploy.yml (github-provider) + CI Go (build/vet/fmt/test) + README/.env.example Go. Gates verts : build, vet, gofmt, go test. Claude-Session: https://claude.ai/code/session_01SsG4T6WHAC2RrTFJkBspf3 --- .dockerignore | 5 + .env.example | 50 +- .github/workflows/ci.yml | 25 +- .gitignore | 15 +- Dockerfile | 16 + README.md | 54 +- app/[transport]/route.ts | 71 - app/api/oauth-prm/route.ts | 27 - app/apple-icon.png | Bin 1506 -> 0 bytes app/health/route.ts | 36 - app/icon.svg | 7 - app/layout.tsx | 58 - app/page.tsx | 184 -- {go/cmd => cmd}/mcp/main.go | 0 components/copy-button.tsx | 40 - components/footics-logo.tsx | 36 - db/index.ts | 9 - db/schema.ts | 377 ---- deploy/compose.dokploy.yml | 26 + go/go.mod => go.mod | 0 go/go.sum => go.sum | 0 go/.env.example | 43 - go/README.md | 116 - .../apiclient/apiclient.go | 41 + internal/apiclient/submit_test.go | 72 + {go/internal => internal}/auth/auth.go | 0 {go/internal => internal}/auth/auth_test.go | 0 {go/internal => internal}/config/config.go | 0 .../config/config_test.go | 0 .../tools/integration_test.go | 2 +- {go/internal => internal}/tools/ratelimit.go | 0 {go/internal => internal}/tools/schemas.go | 0 {go/internal => internal}/tools/shapes.go | 0 .../tools/shapes_test.go | 0 {go/internal => internal}/tools/tools.go | 44 +- lib/auth.ts | 57 - lib/competitions.ts | 26 - lib/config.ts | 32 - lib/jokers.ts | 49 - lib/predictions.ts | 86 - lib/queries.ts | 408 ---- lib/rate-limit.ts | 29 - lib/tools.ts | 233 -- lib/types.ts | 25 - next.config.ts | 16 - package.json | 43 - pnpm-lock.yaml | 1995 ----------------- scripts/mint-test-token.ts | 49 - scripts/oauth-e2e.mjs | 151 -- tsconfig.json | 41 - vercel.json | 5 - 51 files changed, 261 insertions(+), 4338 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile delete mode 100644 app/[transport]/route.ts delete mode 100644 app/api/oauth-prm/route.ts delete mode 100644 app/apple-icon.png delete mode 100644 app/health/route.ts delete mode 100644 app/icon.svg delete mode 100644 app/layout.tsx delete mode 100644 app/page.tsx rename {go/cmd => cmd}/mcp/main.go (100%) delete mode 100644 components/copy-button.tsx delete mode 100644 components/footics-logo.tsx delete mode 100644 db/index.ts delete mode 100644 db/schema.ts create mode 100644 deploy/compose.dokploy.yml rename go/go.mod => go.mod (100%) rename go/go.sum => go.sum (100%) delete mode 100644 go/.env.example delete mode 100644 go/README.md rename {go/internal => internal}/apiclient/apiclient.go (84%) create mode 100644 internal/apiclient/submit_test.go rename {go/internal => internal}/auth/auth.go (100%) rename {go/internal => internal}/auth/auth_test.go (100%) rename {go/internal => internal}/config/config.go (100%) rename {go/internal => internal}/config/config_test.go (100%) rename {go/internal => internal}/tools/integration_test.go (99%) rename {go/internal => internal}/tools/ratelimit.go (100%) rename {go/internal => internal}/tools/schemas.go (100%) rename {go/internal => internal}/tools/shapes.go (100%) rename {go/internal => internal}/tools/shapes_test.go (100%) rename {go/internal => internal}/tools/tools.go (90%) delete mode 100644 lib/auth.ts delete mode 100644 lib/competitions.ts delete mode 100644 lib/config.ts delete mode 100644 lib/jokers.ts delete mode 100644 lib/predictions.ts delete mode 100644 lib/queries.ts delete mode 100644 lib/rate-limit.ts delete mode 100644 lib/tools.ts delete mode 100644 lib/types.ts delete mode 100644 next.config.ts delete mode 100644 package.json delete mode 100644 pnpm-lock.yaml delete mode 100644 scripts/mint-test-token.ts delete mode 100644 scripts/oauth-e2e.mjs delete mode 100644 tsconfig.json delete mode 100644 vercel.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6efceb5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.github +deploy +*.md +.env* diff --git a/.env.example b/.env.example index d7fe7fe..3440ab9 100644 --- a/.env.example +++ b/.env.example @@ -1,42 +1,36 @@ -# Footics MCP — environment variables -# Copy this file to .env.local for local development, and set the same values in -# the Vercel project (Settings -> Environment Variables). Point at a staging -# project to test safely. +# Footics MCP (Go) — environment variables. +# The server is a thin authenticated client of the Footics REST API (/v1): +# it holds no database connection and no service-role key. -# Supabase database (SAME project as the Footics app). Use the pgbouncer pooler -# (port 6543, ?pgbouncer=true) — db/index.ts runs with prepare:false. -DATABASE_URL="postgresql://postgres.:@aws-0-eu-central-1.pooler.supabase.com:6543/postgres" +# Listen address. +HTTP_ADDR=":8080" -# Supabase project (used to verify tokens). Either naming convention is accepted. -NEXT_PUBLIC_SUPABASE_URL="https://.supabase.co" -NEXT_PUBLIC_SUPABASE_ANON_KEY="" +# Footics API base URL (every read/write goes through it, with the user's token). +FOOTICS_API_URL="https://api.footics.app" -# Public URL of the MCP server (custom domain). Used to build the OAuth resource URI. +# User-token verification — pick one mode: +# JWKS (asymmetric, production): comma-separated JWKS URL(s). +AUTH_JWKS_URL="https://api.footics.app/auth/v1/.well-known/jwks.json" +# HS256 (symmetric, local dev against a local GoTrue): +# AUTH_JWT_SECRET="" +AUTH_JWT_AUD="authenticated" + +# OAuth resource metadata served to MCP clients (.well-known/oauth-protected-resource). MCP_PUBLIC_URL="https://mcp.footics.app" +MCP_OAUTH_ISSUER="https://api.footics.app/auth/v1" -# Kill switch. "true" (default) = serving. "false" = /mcp returns 503 for every -# request WITHOUT checking the token or running a tool (no Supabase/DB traffic). +# Kill switch. "true" (default) = serving; "false" = /mcp answers 503 without +# checking tokens or running tools. MCP_ENABLED="true" # Require auth? "true" (default). "false" = temporary authless test mode only. MCP_REQUIRE_AUTH="true" -# Allow writing predictions? "false" (default, read-only). Set "true" only when -# you are ready to expose submit_prediction (guardrails ported from the app). -MCP_ENABLE_WRITES="false" - -# Token verification: "getuser" (default, robust) or "jwks" (local, asymmetric). -MCP_TOKEN_VERIFY="getuser" +# Allow writing predictions? "false" = read-only. Production runs "true". +MCP_ENABLE_WRITES="true" -# OAuth issuer advertised in the resource metadata. Default = SUPABASE_URL/auth/v1. -# MCP_OAUTH_ISSUER="https://.supabase.co/auth/v1" +# Per-user tool-call rate limit (calls/minute/user). 0 = off. Default: 30. +MCP_RATE_LIMIT_PER_MIN=30 # Identity fallback in authless mode (MCP_REQUIRE_AUTH=false): test account id. # MCP_TEST_USER_ID="00000000-0000-0000-0000-000000000000" - -# For `pnpm token` (mints a test Supabase access token): -# TEST_EMAIL="you@example.com" -# TEST_PASSWORD="your-password" - -# Per-user tool-call rate limit (calls/minute/user). 0 = off. Default: 30. -MCP_RATE_LIMIT_PER_MIN=30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad9035d..6c8c52e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,24 +6,15 @@ on: pull_request: jobs: - build: + check: + name: build · vet · fmt · test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - version: 10.2.1 - - - uses: actions/setup-node@v4 + - uses: actions/setup-go@v5 with: - node-version: 22 - cache: pnpm - - - run: pnpm install --frozen-lockfile - - - name: Typecheck - run: pnpm typecheck - - - name: Build - run: pnpm build + go-version-file: go.mod + - run: go build ./... + - run: go vet ./... + - run: test -z "$(gofmt -l .)" + - run: go test ./... diff --git a/.gitignore b/.gitignore index 145bd93..0408008 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,7 @@ -# deps -node_modules -.pnp -.pnp.* - -# next -.next -out -next-env.d.ts -*.tsbuildinfo +# binaries +/mcp +footics-mcp-linux +*.exe # env (never commit secrets) .env @@ -17,6 +11,5 @@ next-env.d.ts # misc .DS_Store *.log -.vercel .idea .vscode diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1329f97 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# footics-mcp — multi-stage build → tiny static binary (same pattern as footics-api). + +# ---- build ---- +FROM golang:1.25 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +ENV CGO_ENABLED=0 GOOS=linux +RUN go build -trimpath -ldflags="-s -w" -o /mcp ./cmd/mcp + +# ---- runtime ---- +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /mcp /mcp +EXPOSE 8080 +ENTRYPOINT ["/mcp"] diff --git a/README.md b/README.md index e931a6e..580f9ae 100644 --- a/README.md +++ b/README.md @@ -7,54 +7,66 @@ account, and the assistant can read their matches, standings and predictions — and, optionally, submit them. Inference runs on the user's side; Footics only pays for hosting. -Separate Vercel project from the main site, **same Supabase database**. Built with -Next.js 16 (App Router) and [`mcp-handler`](https://github.com/vercel/mcp-handler). +Written in **Go** (`cmd/mcp`) with the official +[`modelcontextprotocol/go-sdk`](https://github.com/modelcontextprotocol/go-sdk). +The server is a **thin authenticated client of the Footics REST API** (`/v1`): +it holds no database connection and no service-role key — every read and write +is forwarded to the API with the calling user's token, so the API stays the +single source of truth and the single write point. ## Architecture ``` -AI assistant (Claude / ChatGPT) ──MCP/HTTP──► app/[transport]/route.ts (/mcp) - │ withMcpAuth → lib/auth (verify Supabase JWT) - │ lib/tools → lib/queries (reads) - │ → lib/predictions (writes, gated) +AI assistant (Claude / ChatGPT) ──MCP/HTTP──► cmd/mcp (/mcp) + │ internal/auth verify user JWT (JWKS or HS256) + │ internal/tools → internal/apiclient (calls /v1 with the user token) ▼ - Supabase Postgres (shared with the site) + Footics API (api.footics.app) ──► Postgres ``` -- **`lib/queries.ts`** — lean reads that return raw JSON for the model, not the UI view-models. -- **`lib/predictions.ts`** — writes with the same guardrails as the main app. Off by default (`MCP_ENABLE_WRITES=false`). -- **`db/schema.ts`**, **`db/index.ts`**, **`lib/jokers.ts`** — mirror the main app's schema and rules so column contracts never drift. +- **`internal/auth`** — verifies Supabase-issued user JWTs (JWKS asymmetric in + prod, HS256 for local dev), same JWKS cache + refresh cooldown as the API. +- **`internal/apiclient`** — forwards each tool call to the Footics `/v1` API + with the user's bearer token (no privileged credentials here). +- **`internal/tools`** — the MCP tool definitions + per-user rate limiting. ## Tools `whoami` · `list_matches` · `get_match` · `get_my_standing` · `get_my_predictions` · `get_leaderboard` · `list_my_groups` · `get_joker_status` · `search` · -`submit_prediction` *(gated)* +`submit_prediction` *(gated by `MCP_ENABLE_WRITES`)* ## Configuration | Variable | Default | Effect | |---|---|---| -| `MCP_ENABLED` | `true` | `false` → `/mcp` returns 503 with no Supabase/DB call (kill switch) | +| `FOOTICS_API_URL` | — | base URL of the Footics REST API | +| `AUTH_JWKS_URL` | — | JWKS URL(s) for asymmetric token verification (prod) | +| `AUTH_JWT_SECRET` | — | HS256 secret (local dev alternative to JWKS) | +| `MCP_ENABLED` | `true` | `false` → `/mcp` returns 503 without verifying tokens (kill switch) | | `MCP_REQUIRE_AUTH` | `true` | require a valid bearer token (401 otherwise) | | `MCP_ENABLE_WRITES` | `false` | allow `submit_prediction` | -| `MCP_TOKEN_VERIFY` | `getuser` | `getuser` (robust) or `jwks` (local, asymmetric) | | `MCP_RATE_LIMIT_PER_MIN` | `30` | tool calls per minute per user (`0` = off) | -See [`.env.example`](./.env.example) for the full list, including the Supabase -connection variables. +See [`.env.example`](./.env.example) for the full list. ## Development ```bash -pnpm install -cp .env.example .env.local # fill in — point at a staging project, not production -pnpm dev # http://localhost:3000/mcp -pnpm token # mint a test access token + ready-to-paste MCP Inspector commands +cp .env.example .env # point AUTH at a local/staging GoTrue, not production +go run ./cmd/mcp # serves on $HTTP_ADDR (default :8080), /mcp ``` -`pnpm token` prints an MCP Inspector command: run `tools/list`, then `whoami`, -then any tool. Without an `Authorization` header the endpoint returns `401`. +Without an `Authorization: Bearer ` header the endpoint returns `401` +(unless `MCP_REQUIRE_AUTH=false`). Build a static binary with +`go build ./cmd/mcp`; the container image is the multi-stage +[`Dockerfile`](./Dockerfile) (distroless). + +## Deployment + +Runs on the Footics VPS as a Dokploy service (`footics-mcp`), build-from-git via +[`deploy/compose.dokploy.yml`](./deploy/compose.dokploy.yml) → published at +`mcp.footics.app`. Runbook lives in the private infra repo. ## Security diff --git a/app/[transport]/route.ts b/app/[transport]/route.ts deleted file mode 100644 index d387258..0000000 --- a/app/[transport]/route.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { createMcpHandler, withMcpAuth } from "mcp-handler"; -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; -import { verifyToken } from "@/lib/auth"; -import { ENABLE_WRITES, MCP_ENABLED, REQUIRE_AUTH, SCOPE_READ, SCOPE_WRITE, configWarnings } from "@/lib/config"; -import { registerTools } from "@/lib/tools"; - -export const runtime = "nodejs"; -export const maxDuration = 60; - -for (const w of configWarnings()) console.warn("[mcp-config]", w); - -const handler = createMcpHandler( - (server) => registerTools(server), - {}, - { basePath: "", maxDuration: 60, verboseLogs: process.env.NODE_ENV !== "production" }, -); - -const verify = async (_req: Request, bearerToken?: string): Promise => { - const id = await verifyToken(bearerToken); - if (!id) return undefined; - const scopes = ENABLE_WRITES ? [SCOPE_READ, SCOPE_WRITE] : [SCOPE_READ]; - return { - token: bearerToken as string, - clientId: id.userId, - scopes, - extra: { userId: id.userId, email: id.email }, - }; -}; - -const authed = withMcpAuth(handler, verify, { - required: REQUIRE_AUTH, - resourceMetadataPath: "/.well-known/oauth-protected-resource/mcp", -}); - -const CORS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id, MCP-Protocol-Version, Accept", - "Access-Control-Expose-Headers": "Mcp-Session-Id, WWW-Authenticate", -}; - -function withCors(h: (req: Request) => Promise) { - return async (req: Request): Promise => { - const res = await h(req); - const out = new Response(res.body, res); - for (const [k, v] of Object.entries(CORS)) out.headers.set(k, v); - return out; - }; -} - -async function serviceOff(): Promise { - return Response.json( - { - jsonrpc: "2.0", - error: { code: -32000, message: "Footics MCP est temporairement hors service (maintenance). Réessaie plus tard." }, - id: null, - }, - { status: 503, headers: { "Retry-After": "3600" } }, - ); -} - -const gated = (req: Request): Promise => (MCP_ENABLED ? authed(req) : serviceOff()); - -const GET = withCors(gated); -const POST = withCors(gated); - -export async function OPTIONS(): Promise { - return new Response(null, { status: 204, headers: CORS }); -} - -export { GET, POST }; diff --git a/app/api/oauth-prm/route.ts b/app/api/oauth-prm/route.ts deleted file mode 100644 index 2600d5e..0000000 --- a/app/api/oauth-prm/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { MCP_RESOURCE, OAUTH_ISSUER } from "@/lib/config"; - -export const runtime = "nodejs"; - -export async function GET(): Promise { - const body = { - resource: MCP_RESOURCE, - authorization_servers: OAUTH_ISSUER ? [OAUTH_ISSUER] : [], - bearer_methods_supported: ["header"], - scopes_supported: ["openid", "email", "profile"], - resource_name: "Footics MCP", - resource_documentation: "https://footics.app", - }; - return Response.json(body, { - headers: { - "Access-Control-Allow-Origin": "*", - "Cache-Control": "public, max-age=3600", - }, - }); -} - -export async function OPTIONS(): Promise { - return new Response(null, { - status: 204, - headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "*" }, - }); -} diff --git a/app/apple-icon.png b/app/apple-icon.png deleted file mode 100644 index 682acc4289918f41662644e0d18c892274bea4bb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1506 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbofpxm4i(^Q|oHuuUb3{^Qj(_}r zCMP;GQ&A*JL$^!ihtS079JU-Q*lj8=R~Qz3Sa#^;~C zdL_;CLwzA5Q=x+rpT`l#P74J=c9lmBE;183n1#p@e9ANV2y@_u9d;SBVi35nMM%UN4tJe*S0uda?Pkeb&Do&)478$LQ^S;GfLQ z%ynn?Zc@p~-usvV_}~V?siuCkw^I7EvLTE=brug%{*hc3Vv?3xFoEzV)dOLITvQ!;Ff!GsKB?|y7X%7o$bgm60X{z%=_@wv==YMKC8`%m)^VU z$RszJe@_qH`K5k#bH?383wB-;a+SH~s&6NC#`8x&@*V}QMc1cAF4`1hmmj-RX?pjY zeZ_x|ovnGk(Y}7B?OK`krAkqD+qdj7Jb3i7&f_18azFR%YH>ew@kE#?cn5CD+NauXQFDs*Vprv~MG4nTqHRtKImyVe>Xtf| zwl39O9=mk*rnV9N8mtGFIZ4VY~nHbvvDMUKTxHAL-O_c=^PnJ5%rFs-?aQ zJ6Njrt>?(Y@R}ccmTLU_xJPs@(274@^}8O#B)z{@uO(}deNvI{e4CrMb$_(hqj){_ zYIT{yitt-EALogk*`E}B4;W7WdPTaetIuq{^sxBdj|Hk-7MBFxTM7Q&v;Hb?zv%WY z(U+F}QMzINdtd(f^4lpz8|&|!OgSlPXA`pXWu?6Q(Va;#KOQXLy|-zJ?_Z03ckh3@ z$v68?+woJiAvqOlCsb{s#O+V+p4Xy2#d=2ArN@1-dO0=^)~RoE@7F%sy7ukIcaxv7 z7cM#Vls#|%UVXdi2e`INYl@it5vYHcrKaJ;qU5{aot~ ztMT)BC=$+`r#@NVtJ=+f|2N;Uz02#~zV`p>_w8fZ+k2Z!Z { - if (!MCP_ENABLED) { - return Response.json( - { ok: true, enabled: false, resource: MCP_RESOURCE, reason: "MCP_ENABLED=false — service coupé volontairement (maintenance), aucun appel Supabase/DB." }, - { status: 200, headers: { "Cache-Control": "no-store" } }, - ); - } - - let dbOk = false; - let dbError: string | null = null; - try { - await db.execute(sql`select 1`); - dbOk = true; - } catch (err) { - dbError = err instanceof Error ? err.message : String(err); - } - - return Response.json( - { - ok: dbOk, - enabled: true, - resource: MCP_RESOURCE, - auth: { required: REQUIRE_AUTH, tokenVerify: TOKEN_VERIFY }, - writes: ENABLE_WRITES, - rateLimitPerMin: RATE_LIMIT_PER_MIN, - db: { ok: dbOk, error: dbError }, - }, - { status: dbOk ? 200 : 503, headers: { "Cache-Control": "no-store" } }, - ); -} diff --git a/app/icon.svg b/app/icon.svg deleted file mode 100644 index 291dcba..0000000 --- a/app/icon.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - diff --git a/app/layout.tsx b/app/layout.tsx deleted file mode 100644 index 9e934fd..0000000 --- a/app/layout.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { ReactNode } from "react"; -import { Bricolage_Grotesque } from "next/font/google"; - -const bricolage = Bricolage_Grotesque({ subsets: ["latin"], weight: ["700", "800"], variable: "--font-bricolage" }); - -export const metadata = { - metadataBase: new URL("https://mcp.footics.app"), - title: "Footics MCP — connecte ton assistant IA à tes pronos", - description: - "Branche Claude, ChatGPT, Codex ou Cursor sur ton compte Footics : classement, pronos et matchs de la Coupe du monde, directement dans la conversation. Lecture seule, connexion sécurisée.", - openGraph: { - title: "Footics MCP — parle à tes pronos", - description: "Connecte ton assistant IA à ton compte Footics en 2 minutes.", - url: "https://mcp.footics.app", - siteName: "Footics MCP", - locale: "fr_FR", - type: "website", - }, -}; - -export default function RootLayout({ children }: { children: ReactNode }) { - return ( - - - - {children} - - - ); -} diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 9ac2495..0000000 --- a/app/page.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import type { CSSProperties, ReactNode } from "react"; -import { CopyButton } from "@/components/copy-button"; -import { FooticsLogo } from "@/components/footics-logo"; -import { MCP_RESOURCE } from "@/lib/config"; - -const display: CSSProperties = { fontFamily: "var(--font-bricolage), ui-sans-serif", letterSpacing: "-0.03em" }; -const card: CSSProperties = { background: "var(--f-surface)", borderRadius: 22, padding: "24px 26px", boxShadow: "var(--f-shadow-card)" }; -const h2Style: CSSProperties = { ...display, fontSize: 22, fontWeight: 800, margin: "0 0 4px" }; -const stepText: CSSProperties = { fontSize: 14.5, color: "var(--f-ink-2)", lineHeight: 1.6 }; - -function Code({ children }: { children: string }) { - return ( -
-
-        {children}
-      
- -
- ); -} - -function Badge({ children, tone = "soft" }: { children: ReactNode; tone?: "soft" | "ink" }) { - return ( - - {children} - - ); -} - -function ClientCard({ name, badge, children }: { name: string; badge?: string; children: ReactNode }) { - return ( -
-
-

{name}

- {badge && {badge}} -
-
{children}
-
- ); -} - -const CURSOR_JSON = `{ - "mcpServers": { - "footics": { "url": "${MCP_RESOURCE}" } - } -}`; - -export default function Home() { - return ( -
-
- - - MCP - - - footics.app → - -
- -

- Parle à tes pronos. -

-

- Connecte ton assistant IA à ton compte Footics : consulte ton classement, suis les matchs de la Coupe du monde - et pose tes pronos, directement dans la conversation. -

-

- Connexion « Sign in with Footics » · tes pronos restent les tiens · gratuit, comme le reste. -

- -
-
- L'adresse à connaître -
- {MCP_RESOURCE} -

- C'est tout ce dont ton assistant a besoin. Colle-la dans l'outil de ton choix ci-dessous, connecte-toi avec ton - compte Footics quand il te le demande, et c'est parti. -

-
- -
- -
    -
  1. - Réglages → Connecteurs → Ajouter un connecteur personnalisé -
  2. -
  3. - Colle l'URL : {MCP_RESOURCE} -
  4. -
  5. - Clique « Se connecter » → identifie-toi sur footics.app → Autoriser. -
  6. -
-

- Connecteurs personnalisés disponibles sur les plans Claude Pro, Max, Team et Enterprise. -

-
- - - {`claude mcp add --transport http footics ${MCP_RESOURCE}`} -

- Puis dans une session : tape /mcp, - choisis footicsAuthenticate, et connecte-toi dans le navigateur. -

-
- - - {`codex mcp add footics --url ${MCP_RESOURCE}`} - {`codex mcp login footics`} - - - -
    -
  1. - Paramètres → Connecteurs → Paramètres avancés : active le mode développeur -
  2. -
  3. - Ajouter un connecteur personnalisé → colle l'URL ci-dessus -
  4. -
  5. Connecte-toi avec ton compte Footics, puis active le connecteur dans ta conversation.
  6. -
-
- - -

- Dans .cursor/mcp.json (projet) ou{" "} - ~/.cursor/mcp.json (global) : -

- {CURSOR_JSON} -
- - -

- Tout client qui parle MCP Streamable HTTP + OAuth fonctionne : donne-lui l'URL{" "} - {MCP_RESOURCE} — la découverte, - l'enregistrement et la connexion sont automatiques (RFC 9728, PKCE, refresh). -

-
-
- -
-

Ensuite, demande-lui…

-
- {["Quel est mon classement ?", "Les matchs de ce soir ?", "Pose 2-1 sur le match de la France", "Le top 5 de mon groupe ?", "Il me reste combien de jokers ?"].map((q) => ( - - « {q} » - - ))} -
-
- -
-

Sécurité, en deux mots

-
    -
  • - Tu te connectes sur footics.app (OAuth 2.1) — ton mot de passe n'est jamais partagé avec l'assistant. -
  • -
  • - L'IA ne peut écrire qu'une seule chose : tes propres pronos, avec les mêmes règles que l'app - (verrouillage au coup d'envoi, scores 0-20, quota de jokers) — et ton assistant te demande confirmation avant de poser. -
  • -
  • Tu peux refuser ou déconnecter le connecteur à tout moment, depuis ton assistant.
  • -
-
- - -
- ); -} diff --git a/go/cmd/mcp/main.go b/cmd/mcp/main.go similarity index 100% rename from go/cmd/mcp/main.go rename to cmd/mcp/main.go diff --git a/components/copy-button.tsx b/components/copy-button.tsx deleted file mode 100644 index 8fc413b..0000000 --- a/components/copy-button.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { useState } from "react"; - -export function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); - - async function copy() { - try { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 1600); - } catch { - // clipboard API unavailable; the command text stays selectable - } - } - - return ( - - ); -} diff --git a/components/footics-logo.tsx b/components/footics-logo.tsx deleted file mode 100644 index 4d4c132..0000000 --- a/components/footics-logo.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { CSSProperties } from "react"; - -export function FooticsLogo({ size = 24, color, mono = false }: { size?: number; color?: string; mono?: boolean }) { - const c = color || "var(--f-ink)"; - const accent = mono ? c : "var(--f-primary)"; - const display: CSSProperties = { fontFamily: "var(--font-bricolage), ui-sans-serif", fontWeight: 800, fontSize: size, letterSpacing: 0 }; - return ( - - Foo - - t - - ı - - - cs - - - ); -} diff --git a/db/index.ts b/db/index.ts deleted file mode 100644 index 3287e59..0000000 --- a/db/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; -import * as schema from "./schema"; - -const connectionString = process.env.DATABASE_URL ?? ""; -const client = postgres(connectionString, { prepare: false }); - -export const db = drizzle(client, { schema }); -export { schema }; diff --git a/db/schema.ts b/db/schema.ts deleted file mode 100644 index 755b61d..0000000 --- a/db/schema.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { relations, sql } from "drizzle-orm"; -import { - boolean, - check, - index, - integer, - pgEnum, - pgTable, - primaryKey, - smallint, - text, - timestamp, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core"; - -export const phaseEnum = pgEnum("phase", [ - "group", - "round_of_32", - "round_of_16", - "quarter_final", - "semi_final", - "third_place", - "final", -]); - -export const matchStatusEnum = pgEnum("match_status", ["scheduled", "live", "finished", "postponed", "cancelled"]); - -export const scoringRuleEnum = pgEnum("scoring_rule", ["simple", "knockout"]); - -export const matchPeriodEnum = pgEnum("match_period", [ - "pre", - "first_half", - "half_time", - "second_half", - "et_break", - "et_first", - "et_second", - "penalties", - "full_time", -]); - -export const matchEventTypeEnum = pgEnum("match_event_type", [ - "goal", - "own_goal", - "penalty_goal", - "penalty_missed", - "yellow_card", - "second_yellow", - "red_card", - "substitution", - "var", - "shootout_goal", - "shootout_miss", -]); - -export const userRoleEnum = pgEnum("user_role", ["member", "admin"]); - -export const competitions = pgTable( - "competitions", - { - id: uuid("id").defaultRandom().primaryKey(), - slug: varchar("slug", { length: 24 }).notNull().unique(), - label: text("label").notNull(), - short: varchar("short", { length: 16 }).notNull(), - emoji: varchar("emoji", { length: 16 }), - hasPhases: boolean("has_phases").notNull().default(false), - hasJokers: boolean("has_jokers").notNull().default(true), - sortOrder: smallint("sort_order").notNull().default(0), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [uniqueIndex("competitions_slug_idx").on(t.slug)], -); - -export const teams = pgTable( - "teams", - { - code: varchar("code", { length: 3 }).primaryKey(), - name: text("name").notNull(), - iso2: varchar("iso2", { length: 6 }).notNull(), - }, - (t) => [ - check("teams_iso2_format", sql`${t.iso2} ~ '^[a-z]{2}(-[a-z]{2,3})?$'`), - ], -); - -export const users = pgTable( - "users", - { - id: uuid("id").primaryKey(), - username: varchar("username", { length: 24 }).notNull(), - email: text("email").notNull(), - avatarUrl: text("avatar_url"), - role: userRoleEnum("role").notNull().default("member"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [ - uniqueIndex("users_username_lower_idx").on(sql`lower(${t.username})`), - check("users_username_format", sql`${t.username} ~ '^[A-Za-z0-9_]{2,24}$'`), - ], -); - -export function usernameMatches(value: string) { - return sql`lower(${users.username}) = lower(${value})`; -} - -export const groups = pgTable( - "groups", - { - id: uuid("id").defaultRandom().primaryKey(), - name: varchar("name", { length: 60 }).notNull(), - description: varchar("description", { length: 280 }), - emoji: varchar("emoji", { length: 16 }), - inviteCode: varchar("invite_code", { length: 6 }).notNull().unique(), - ownerId: uuid("owner_id") - .notNull() - .references(() => users.id, { onDelete: "restrict" }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [ - uniqueIndex("groups_invite_code_idx").on(t.inviteCode), - check("groups_name_len", sql`char_length(${t.name}) BETWEEN 2 AND 60`), - check("groups_invite_code_format", sql`${t.inviteCode} ~ '^[A-HJ-NP-Z2-9]{6}$'`), - ], -); - -export const groupMembers = pgTable( - "group_members", - { - groupId: uuid("group_id") - .notNull() - .references(() => groups.id, { onDelete: "cascade" }), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [primaryKey({ columns: [t.groupId, t.userId] }), index("group_members_user_idx").on(t.userId)], -); - -export const matches = pgTable( - "matches", - { - id: uuid("id").defaultRandom().primaryKey(), - externalId: integer("external_id").notNull().unique(), - competitionId: uuid("competition_id") - .notNull() - .references(() => competitions.id, { onDelete: "restrict" }), - phase: phaseEnum("phase").notNull(), - scoringRule: scoringRuleEnum("scoring_rule").notNull(), - groupLabel: varchar("group_label", { length: 2 }), - homeTeamCode: varchar("home_team_code", { length: 3 }) - .notNull() - .references(() => teams.code, { onDelete: "restrict" }), - awayTeamCode: varchar("away_team_code", { length: 3 }) - .notNull() - .references(() => teams.code, { onDelete: "restrict" }), - venue: text("venue"), - kickoffAt: timestamp("kickoff_at", { withTimezone: true }).notNull(), - - status: matchStatusEnum("status").notNull().default("scheduled"), - homeScore: integer("home_score"), - awayScore: integer("away_score"), - winnerTeamCode: varchar("winner_team_code", { length: 3 }).references(() => teams.code, { onDelete: "restrict" }), - scoredAt: timestamp("scored_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [ - uniqueIndex("matches_external_id_idx").on(t.externalId), - index("matches_competition_kickoff_idx").on(t.competitionId, t.kickoffAt), - index("matches_phase_idx").on(t.phase), - index("matches_kickoff_idx").on(t.kickoffAt), - index("matches_status_idx").on(t.status), - check( - "matches_score_check", - sql`(${t.status} = 'finished' AND ${t.homeScore} IS NOT NULL AND ${t.awayScore} IS NOT NULL) - OR (${t.status} != 'finished')`, - ), - check( - "matches_winner_in_match", - sql`${t.winnerTeamCode} IS NULL OR ${t.winnerTeamCode} IN (${t.homeTeamCode}, ${t.awayTeamCode})`, - ), - check( - "matches_winner_ko_draw", - sql`${t.winnerTeamCode} IS NULL OR (${t.phase} <> 'group' AND ${t.homeScore} = ${t.awayScore})`, - ), - check("matches_scoring_rule_phase", sql`NOT (${t.phase} <> 'group' AND ${t.scoringRule} = 'simple')`), - ], -); - -export const matchLive = pgTable( - "match_live", - { - matchId: uuid("match_id") - .primaryKey() - .references(() => matches.id, { onDelete: "cascade" }), - period: matchPeriodEnum("period").notNull().default("pre"), - periodStartedAt: timestamp("period_started_at", { withTimezone: true }), - running: boolean("running").notNull().default(false), - addedTime: smallint("added_time"), - displayMinute: smallint("display_minute"), - statusNote: text("status_note"), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [ - index("match_live_period_idx").on(t.period), - check( - "match_live_running_period", - sql`${t.running} = false OR ${t.period} IN ('first_half','second_half','et_first','et_second')`, - ), - check("match_live_running_anchor", sql`${t.running} = false OR ${t.periodStartedAt} IS NOT NULL`), - check("match_live_added_time_positive", sql`${t.addedTime} IS NULL OR ${t.addedTime} >= 0`), - check("match_live_display_minute_positive", sql`${t.displayMinute} IS NULL OR ${t.displayMinute} >= 0`), - ], -); - -export const matchEvents = pgTable( - "match_events", - { - id: uuid("id").defaultRandom().primaryKey(), - matchId: uuid("match_id") - .notNull() - .references(() => matches.id, { onDelete: "cascade" }), - type: matchEventTypeEnum("type").notNull(), - period: matchPeriodEnum("period").notNull(), - minute: smallint("minute").notNull(), - minutePlus: smallint("minute_plus"), - teamCode: varchar("team_code", { length: 3 }).references(() => teams.code, { onDelete: "restrict" }), - player: text("player"), - assist: text("assist"), - detail: text("detail"), - sortOrder: integer("sort_order").notNull().default(0), - dedupKey: text("dedup_key").notNull().unique(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [ - uniqueIndex("match_events_dedup_idx").on(t.dedupKey), - index("match_events_order_idx").on(t.matchId, t.minute, t.sortOrder), - check("match_events_minute_positive", sql`${t.minute} >= 0`), - check("match_events_plus_positive", sql`${t.minutePlus} IS NULL OR ${t.minutePlus} >= 0`), - check("match_events_team_required", sql`${t.teamCode} IS NOT NULL OR ${t.type} = 'var'`), - check( - "match_events_shootout_period", - sql`${t.type} NOT IN ('shootout_goal','shootout_miss') OR ${t.period} = 'penalties'`, - ), - ], -); - -export const predictions = pgTable( - "predictions", - { - id: uuid("id").defaultRandom().primaryKey(), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - matchId: uuid("match_id") - .notNull() - .references(() => matches.id, { onDelete: "cascade" }), - homeScore: integer("home_score").notNull(), - awayScore: integer("away_score").notNull(), - winnerTeamCode: varchar("winner_team_code", { length: 3 }), - jokerApplied: boolean("joker_applied").notNull().default(false), - pointsAwarded: integer("points_awarded"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [ - uniqueIndex("predictions_user_match_idx").on(t.userId, t.matchId), - index("predictions_match_idx").on(t.matchId), - index("predictions_user_idx").on(t.userId), - index("predictions_joker_idx").on(t.userId, t.matchId).where(sql`${t.jokerApplied} = true`), - check( - "predictions_score_range_check", - sql`${t.homeScore} >= 0 AND ${t.homeScore} <= 20 AND ${t.awayScore} >= 0 AND ${t.awayScore} <= 20`, - ), - check( - "predictions_winner_implies_draw", - sql`${t.winnerTeamCode} IS NULL OR ${t.homeScore} = ${t.awayScore}`, - ), - ], -); - -export const userCompetitionStandings = pgTable( - "user_competition_standings", - { - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - competitionId: uuid("competition_id") - .notNull() - .references(() => competitions.id, { onDelete: "cascade" }), - pointsTotal: integer("points_total").notNull().default(0), - exactCount: integer("exact_count").notNull().default(0), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (t) => [ - primaryKey({ columns: [t.userId, t.competitionId] }), - index("standings_competition_rank_idx").on(t.competitionId, t.pointsTotal.desc(), t.exactCount.desc()), - ], -); - -export const competitionsRelations = relations(competitions, ({ many }) => ({ - matches: many(matches), - standings: many(userCompetitionStandings), -})); - -export const teamsRelations = relations(teams, ({ many }) => ({ - homeMatches: many(matches, { relationName: "homeTeam" }), - awayMatches: many(matches, { relationName: "awayTeam" }), -})); - -export const usersRelations = relations(users, ({ many }) => ({ - predictions: many(predictions), - memberships: many(groupMembers), - standings: many(userCompetitionStandings), - ownedGroups: many(groups), -})); - -export const groupsRelations = relations(groups, ({ one, many }) => ({ - owner: one(users, { fields: [groups.ownerId], references: [users.id] }), - members: many(groupMembers), -})); - -export const groupMembersRelations = relations(groupMembers, ({ one }) => ({ - group: one(groups, { fields: [groupMembers.groupId], references: [groups.id] }), - user: one(users, { fields: [groupMembers.userId], references: [users.id] }), -})); - -export const matchesRelations = relations(matches, ({ one, many }) => ({ - competition: one(competitions, { fields: [matches.competitionId], references: [competitions.id] }), - homeTeam: one(teams, { fields: [matches.homeTeamCode], references: [teams.code], relationName: "homeTeam" }), - awayTeam: one(teams, { fields: [matches.awayTeamCode], references: [teams.code], relationName: "awayTeam" }), - live: one(matchLive, { fields: [matches.id], references: [matchLive.matchId] }), - predictions: many(predictions), - events: many(matchEvents), -})); - -export const matchLiveRelations = relations(matchLive, ({ one }) => ({ - match: one(matches, { fields: [matchLive.matchId], references: [matches.id] }), -})); - -export const matchEventsRelations = relations(matchEvents, ({ one }) => ({ - match: one(matches, { fields: [matchEvents.matchId], references: [matches.id] }), - team: one(teams, { fields: [matchEvents.teamCode], references: [teams.code] }), -})); - -export const predictionsRelations = relations(predictions, ({ one }) => ({ - user: one(users, { fields: [predictions.userId], references: [users.id] }), - match: one(matches, { fields: [predictions.matchId], references: [matches.id] }), -})); - -export const userCompetitionStandingsRelations = relations(userCompetitionStandings, ({ one }) => ({ - user: one(users, { fields: [userCompetitionStandings.userId], references: [users.id] }), - competition: one(competitions, { fields: [userCompetitionStandings.competitionId], references: [competitions.id] }), -})); - -export type Competition = typeof competitions.$inferSelect; -export type NewCompetition = typeof competitions.$inferInsert; -export type Team = typeof teams.$inferSelect; -export type NewTeam = typeof teams.$inferInsert; -export type User = typeof users.$inferSelect; -export type NewUser = typeof users.$inferInsert; -export type Group = typeof groups.$inferSelect; -export type GroupMember = typeof groupMembers.$inferSelect; -export type Match = typeof matches.$inferSelect; -export type NewMatch = typeof matches.$inferInsert; -export type MatchLive = typeof matchLive.$inferSelect; -export type NewMatchLive = typeof matchLive.$inferInsert; -export type MatchEvent = typeof matchEvents.$inferSelect; -export type NewMatchEvent = typeof matchEvents.$inferInsert; -export type Prediction = typeof predictions.$inferSelect; -export type NewPrediction = typeof predictions.$inferInsert; -export type UserCompetitionStanding = typeof userCompetitionStandings.$inferSelect; -export type NewUserCompetitionStanding = typeof userCompetitionStandings.$inferInsert; diff --git a/deploy/compose.dokploy.yml b/deploy/compose.dokploy.yml new file mode 100644 index 0000000..f4de662 --- /dev/null +++ b/deploy/compose.dokploy.yml @@ -0,0 +1,26 @@ +# Footics MCP — compose du service Dokploy « footics-mcp » (sourceType github, +# app Dokploy-Footics, serveur tidings-prod). Build depuis le Dockerfile du repo. +# Rejoint le socle footics-core via le réseau externe footics-net (api/gotrue par +# alias). Racine / = 404 by design ; sonde = /.well-known/oauth-protected-resource. +# Runbook : repo t0m-car/infra. + +networks: + footics-net: + external: true + +services: + mcp: + build: + context: .. + dockerfile: Dockerfile + image: footics-mcp:local + restart: unless-stopped + networks: [footics-net] + environment: + HTTP_ADDR: ":8080" + FOOTICS_API_URL: http://api:8099 + AUTH_JWKS_URL: http://gotrue:9999/.well-known/jwks.json + AUTH_JWT_AUD: authenticated + MCP_PUBLIC_URL: https://mcp.footics.app + MCP_OAUTH_ISSUER: https://api.footics.app/auth/v1 + MCP_ENABLE_WRITES: "true" diff --git a/go/go.mod b/go.mod similarity index 100% rename from go/go.mod rename to go.mod diff --git a/go/go.sum b/go.sum similarity index 100% rename from go/go.sum rename to go.sum diff --git a/go/.env.example b/go/.env.example deleted file mode 100644 index c1325d5..0000000 --- a/go/.env.example +++ /dev/null @@ -1,43 +0,0 @@ -# Footics MCP (Go) — environment variables -# Copy to .env and export before running ./bin/mcp. No DATABASE_URL: this server -# is a thin client of footics-api /v1, it never touches Postgres. - -# footics-api base URL. In the compose network this is the internal address; for -# local dev point it at the staging API. The client appends /v1/... and relays -# the caller's Bearer. Required. -FOOTICS_API_URL="http://127.0.0.1:8099" - -# JWT verification mode — set EXACTLY ONE (mirrors footics-api internal/auth): -# AUTH_JWKS_URL → asymmetric ES256/RS256, keys fetched + cached from the URL (prod) -# AUTH_JWT_SECRET → HS256 with the legacy Supabase secret (staging/dev) -# AUTH_JWKS_URL="https://.supabase.co/auth/v1/.well-known/jwks.json" -AUTH_JWT_SECRET="" -AUTH_JWT_AUD="authenticated" - -# Public origin of this server; Resource = MCP_PUBLIC_URL + "/mcp" (the OAuth -# protected-resource identifier advertised in the RFC 9728 metadata). -MCP_PUBLIC_URL="https://mcp.footics.app" - -# OAuth issuer advertised as authorization_servers[0] in the metadata (Supabase). -# Empty → the field is omitted. -MCP_OAUTH_ISSUER="https://.supabase.co/auth/v1" - -# HTTP listen address. -HTTP_ADDR=":8080" - -# Kill switch: "false" → every /mcp request gets 503 without touching auth/API. -MCP_ENABLED="true" - -# Require auth? "true" (default). "false" = authless test mode (identity from -# MCP_TEST_USER_ID; reads still need a relayed Bearer, so the API will reject them). -MCP_REQUIRE_AUTH="true" - -# Expose submit_prediction as a real write? Its real path lands with footics-api -# POST /v1/predictions (M4); until then the tool is a stub regardless. -MCP_ENABLE_WRITES="false" - -# Per-user tool-call rate limit (calls/minute/user). 0 = off. Default 30. -MCP_RATE_LIMIT_PER_MIN="30" - -# Identity fallback for authless test mode (MCP_REQUIRE_AUTH=false). -# MCP_TEST_USER_ID="" diff --git a/go/README.md b/go/README.md deleted file mode 100644 index 398ecb1..0000000 --- a/go/README.md +++ /dev/null @@ -1,116 +0,0 @@ -# Footics MCP — Go rewrite (client of `/v1`) - -Go port of the Footics MCP server. It is a **thin HTTP client of `footics-api` -`/v1`**: it verifies the Supabase JWT locally, then relays the caller's Bearer to -the API — **no database access**. This is the staging deliverable for PRD 15 -(`docs/prd/15-mcp-go.md`); it lives in `go/` alongside the untouched Next.js/TS -server at the repo root. - -> **The TypeScript server stays.** Nothing at the repo root is removed or moved. -> The live server (`mcp.footics.app` on Vercel) is unaffected. Whether/when Go -> replaces TS is **Tom's call** — see [Repo structure](#repo-structure). - -## What it does - -- Verifies the Supabase JWT **locally**, double-mode (JWKS ES256/RS256 or HS256), - same logic as `footics-api/internal/auth`. -- Serves RFC 9728 **protected-resource metadata** on both well-known paths + the - `WWW-Authenticate` challenge on 401 → OAuth discovery still points at Supabase. -- Exposes the **10 frozen tools** over Streamable HTTP (stateless). Each read is - one `/v1` call with the Bearer relayed; the output shape is byte-identical to - the TS server (`lib/tools.ts`). `whoami` is served from the JWT alone. - -## Quickstart - -```bash -cd go -cp .env.example .env # point FOOTICS_API_URL at the staging API -set -a && . ./.env && set +a -go run ./cmd/mcp # listens on HTTP_ADDR (default :8080) -``` - -Gate (what CI runs): - -```bash -go build ./... && go vet ./... && gofmt -l . # last must print nothing -go test ./... -``` - -The integration test (`internal/tools/integration_test.go`) stands up the real -stack in front of a running staging API (`http://127.0.0.1:8099`), mints an alice -Bearer, and asserts the frozen shape of every read tool. It **skips** cleanly -when the API is unreachable. - -## Environment - -| Var | Required | Default | Notes | -|---|---|---|---| -| `FOOTICS_API_URL` | ✅ | — | Base URL of `footics-api` (`/v1` appended). No `DATABASE_URL` exists. | -| `AUTH_JWKS_URL` \| `AUTH_JWT_SECRET` | ✅ (exactly one) | — | JWKS (prod) or HS256 (staging). Mutually exclusive. | -| `AUTH_JWT_AUD` | | `authenticated` | Expected `aud` claim. | -| `MCP_PUBLIC_URL` | | `https://mcp.footics.app` | `Resource` = this + `/mcp`. | -| `MCP_OAUTH_ISSUER` | | — | `authorization_servers[0]` in the metadata (Supabase). | -| `HTTP_ADDR` | | `:8080` | | -| `MCP_ENABLED` | | `true` | `false` → 503 kill switch. | -| `MCP_REQUIRE_AUTH` | | `true` | `false` → authless test mode (`MCP_TEST_USER_ID`). | -| `MCP_ENABLE_WRITES` | | `false` | See [submit_prediction](#submit_prediction-awaits-m4). | -| `MCP_RATE_LIMIT_PER_MIN` | | `30` | Per-user; 0 = off. Global on this single process. | - -## Tools → `/v1` endpoints - -| Tool | Endpoint | Notes | -|---|---|---| -| `whoami` | — (JWT) | `{userId, email}` from the token. | -| `list_matches` | `GET /v1/matches?competition=&status=&limit=` | flat array, projected to the frozen `MatchJson`. | -| `get_match` | `GET /v1/matches/{id}` | + `events[]` (`[]` when scheduled); 404 → "Match introuvable." | -| `get_my_standing` | `GET /v1/me?competition=` | projects `{userId,username,competition,points,exact,rank,rankOf}`; 404 → "Profil introuvable." | -| `get_my_predictions` | `GET /v1/predictions?competition=&when=&limit=` | drops `winnerTeamCode`; `result`/`points` present-null. | -| `get_leaderboard` | `GET /v1/leaderboard?competition=&group=&limit=` | projects `rows`. | -| `list_my_groups` | `GET /v1/groups?competition=` | projects `groups` (`owner` bool). | -| `get_joker_status` | `GET /v1/jokers?competition=` | passthrough. | -| `search` | `GET /v1/search?q=` | passthrough (`{matches,groups,users}`). | -| `submit_prediction` | `POST /v1/predictions` | **stub — awaits M4.** | - -Projection notes (to stay byte-identical to `lib/tools.ts`): -- **`kickoffAt`** is normalised to `Date.toISOString()` form (always `.000Z`) — - `/v1/matches` already emits millis, `/v1/predictions` and `/v1/search` do not. -- **`venue`** empty string → `null` (the TS `venue ?? null` for an absent venue). -- **`score`** = the regulation `Score` for finished, `LiveScore` for live, else `null`. -- Input schemas are **fixed explicitly** (`internal/tools/schemas.go`) — enums, - bounds, defaults and FR descriptions — not inferred from Go structs, so - `tools/list` matches the zod-derived TS schemas. - -### `submit_prediction` awaits M4 - -The write path (`footics-api POST /v1/predictions`) ships with **M4** and is not -delivered yet. The tool is registered (prod runs writes ON) but is a **stub**: -with `MCP_ENABLE_WRITES=false` it returns *"écriture pas encore disponible sur -cette instance"*; with writes on it returns a *"arrivera avec l'API /v1 POST -(M4)"* error. Wiring the real call is a one-function change once M4 lands. - -## Layout - -``` -go/ - cmd/mcp/ server wiring (mux, PRM, auth middleware, CORS, kill switch, health) - internal/config/ env parsing (no DATABASE_URL) - internal/auth/ JWT double-mode verifier → go-sdk auth.TokenVerifier - internal/apiclient/ thin /v1 HTTP client (relays the Bearer) - internal/tools/ 10 tools: registration, gate (auth+rate-limit), projections, schemas -``` - -Uses the official SDK `github.com/modelcontextprotocol/go-sdk` (v1.6.1): -`mcp.NewStreamableHTTPHandler` (stateless), `mcp.AddTool`, `auth.RequireBearerToken`, -`auth.ProtectedResourceMetadataHandler`. Routing is stdlib `net/http.ServeMux` -(five static routes — chi would buy nothing here). - -## Repo structure - -Delivered under `go/` so the branch is **purely additive**: not a single TS file -at the repo root is moved or deleted, and the live Vercel server is untouched. -PRD 15 §9 envisions an eventual in-place rewrite (TS → history + a `v0-nextjs` -tag); promoting `go/` to the root and archiving the TS is a **separate decision -for Tom**, out of scope for this staging deliverable. - -Not included here (land with the cutover, N4): Dockerfile / GHCR image, Go CI -workflow, tunnel + compose service. See PRD 15 §10–11. diff --git a/go/internal/apiclient/apiclient.go b/internal/apiclient/apiclient.go similarity index 84% rename from go/internal/apiclient/apiclient.go rename to internal/apiclient/apiclient.go index 3da4629..435fb63 100644 --- a/go/internal/apiclient/apiclient.go +++ b/internal/apiclient/apiclient.go @@ -8,6 +8,7 @@ package apiclient import ( + "bytes" "context" "encoding/json" "fmt" @@ -245,6 +246,46 @@ func (c *Client) Search(ctx context.Context, token, query string) (SearchResults return out, err } +// SubmitPredictionInput mirrors footics-api POST /v1/predictions body. +type SubmitPredictionInput struct { + MatchID string `json:"matchId"` + Home int `json:"home"` + Away int `json:"away"` + Joker bool `json:"joker"` + WinnerTeamCode *string `json:"winnerTeamCode,omitempty"` +} + +// SubmitPrediction → POST /v1/predictions with the user's token. footics-api is +// the single write point + trust boundary (it re-verifies the token, enforces +// the 90' lock, joker quota and KO-qualifier rules). Returns the decoded body +// (whatever the API sent: {ok, prediction} on 2xx, {ok:false, error} otherwise) +// and the HTTP status, so the tool can surface the API's own FR message verbatim. +func (c *Client) SubmitPrediction(ctx context.Context, token string, in SubmitPredictionInput) (map[string]any, int, error) { + body, err := json.Marshal(in) + if err != nil { + return nil, 0, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/v1/predictions", bytes.NewReader(body)) + if err != nil { + return nil, 0, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.hc.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var out map[string]any + if len(raw) > 0 { + _ = json.Unmarshal(raw, &out) // tolerate an empty/odd body; status carries the truth + } + return out, resp.StatusCode, nil +} + // getJSON does an authenticated GET and decodes a 2xx body into dst. It returns // the HTTP status (so callers can special-case 404) and a non-nil error on any // non-2xx status or transport/decode failure. diff --git a/internal/apiclient/submit_test.go b/internal/apiclient/submit_test.go new file mode 100644 index 0000000..8bcef03 --- /dev/null +++ b/internal/apiclient/submit_test.go @@ -0,0 +1,72 @@ +package apiclient + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +// SubmitPrediction forwards the body + Bearer to footics-api and passes the +// decoded response and status straight back (the API is the trust boundary). +func TestSubmitPredictionForwards(t *testing.T) { + var gotMethod, gotPath, gotAuth, gotCT string + var gotBody SubmitPredictionInput + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + gotAuth, gotCT = r.Header.Get("Authorization"), r.Header.Get("Content-Type") + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &gotBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true,"prediction":{"home":2,"away":1,"joker":false}}`)) + })) + defer srv.Close() + + win := "FRA" + body, status, err := New(srv.URL).SubmitPrediction(context.Background(), "tok-abc", SubmitPredictionInput{ + MatchID: "m1", Home: 2, Away: 1, Joker: false, WinnerTeamCode: &win, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotMethod != http.MethodPost || gotPath != "/v1/predictions" { + t.Fatalf("request = %s %s, want POST /v1/predictions", gotMethod, gotPath) + } + if gotAuth != "Bearer tok-abc" { + t.Errorf("Authorization = %q", gotAuth) + } + if gotCT != "application/json" { + t.Errorf("Content-Type = %q", gotCT) + } + if gotBody.MatchID != "m1" || gotBody.Home != 2 || gotBody.Away != 1 || gotBody.WinnerTeamCode == nil || *gotBody.WinnerTeamCode != "FRA" { + t.Errorf("forwarded body = %+v", gotBody) + } + if status != http.StatusOK || body["ok"] != true { + t.Errorf("status=%d body=%v", status, body) + } +} + +// A non-2xx from the API is passed through verbatim (status + {ok:false,error}) +// so the tool layer can surface the API's own FR message. +func TestSubmitPredictionPassesThroughError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"ok":false,"error":"Match verrouillé (coup d'envoi passé)."}`)) + })) + defer srv.Close() + + body, status, err := New(srv.URL).SubmitPrediction(context.Background(), "t", SubmitPredictionInput{MatchID: "m1", Home: 0, Away: 0}) + if err != nil { + t.Fatalf("transport error should be nil for an HTTP error: %v", err) + } + if status != http.StatusConflict { + t.Errorf("status = %d, want 409", status) + } + if body["error"] != "Match verrouillé (coup d'envoi passé)." { + t.Errorf("error message not passed through: %v", body["error"]) + } +} diff --git a/go/internal/auth/auth.go b/internal/auth/auth.go similarity index 100% rename from go/internal/auth/auth.go rename to internal/auth/auth.go diff --git a/go/internal/auth/auth_test.go b/internal/auth/auth_test.go similarity index 100% rename from go/internal/auth/auth_test.go rename to internal/auth/auth_test.go diff --git a/go/internal/config/config.go b/internal/config/config.go similarity index 100% rename from go/internal/config/config.go rename to internal/config/config.go diff --git a/go/internal/config/config_test.go b/internal/config/config_test.go similarity index 100% rename from go/internal/config/config_test.go rename to internal/config/config_test.go diff --git a/go/internal/tools/integration_test.go b/internal/tools/integration_test.go similarity index 99% rename from go/internal/tools/integration_test.go rename to internal/tools/integration_test.go index 5e0479a..66c0a40 100644 --- a/go/internal/tools/integration_test.go +++ b/internal/tools/integration_test.go @@ -272,7 +272,7 @@ func TestIntegrationReadTools(t *testing.T) { t.Error("search 'fra' found no matches (expected FRA-MAR)") } - // submit_prediction — stub: writes OFF ⇒ tool error with the FR message. + // submit_prediction — writes OFF here ⇒ tool error with the FR message. res, err := sess.CallTool(ctx, &mcp.CallToolParams{Name: "submit_prediction", Arguments: map[string]any{"matchId": matchID, "homeScore": 1, "awayScore": 0}}) if err != nil { t.Fatalf("submit_prediction transport error: %v", err) diff --git a/go/internal/tools/ratelimit.go b/internal/tools/ratelimit.go similarity index 100% rename from go/internal/tools/ratelimit.go rename to internal/tools/ratelimit.go diff --git a/go/internal/tools/schemas.go b/internal/tools/schemas.go similarity index 100% rename from go/internal/tools/schemas.go rename to internal/tools/schemas.go diff --git a/go/internal/tools/shapes.go b/internal/tools/shapes.go similarity index 100% rename from go/internal/tools/shapes.go rename to internal/tools/shapes.go diff --git a/go/internal/tools/shapes_test.go b/internal/tools/shapes_test.go similarity index 100% rename from go/internal/tools/shapes_test.go rename to internal/tools/shapes_test.go diff --git a/go/internal/tools/tools.go b/internal/tools/tools.go similarity index 90% rename from go/internal/tools/tools.go rename to internal/tools/tools.go index f8cb311..34aca24 100644 --- a/go/internal/tools/tools.go +++ b/internal/tools/tools.go @@ -1,7 +1,7 @@ // Package tools registers the Footics MCP tools on a go-sdk server. Each read // tool is one authenticated /v1 call (Bearer relayed) projected into the frozen -// output shape (shapes.go). whoami is served from the JWT alone. submit_prediction -// is a stub until footics-api ships POST /v1/predictions (M4). +// output shape (shapes.go). whoami is served from the JWT alone; submit_prediction +// forwards to footics-api POST /v1/predictions (the single write point). // // Output envelope (frozen): success = one text block of pretty-printed JSON // (2-space indent, HTML-escaping OFF, matching JSON.stringify(data,null,2)); @@ -40,7 +40,7 @@ type toolServer struct { rlPerMin int } -// Register adds the 10 tools (9 reads + the submit_prediction stub) to server. +// Register adds the 10 tools (9 reads + submit_prediction) to server. func Register(server *mcp.Server, d Deps) { s := &toolServer{ api: d.API, @@ -113,9 +113,8 @@ func Register(server *mcp.Server, d Deps) { InputSchema: schemaSearch(), }, s.search) - // submit_prediction always appears (prod runs with writes ON), but the real - // write path lands with footics-api POST /v1/predictions (M4). Until then it - // is a stub — see submitPrediction. + // submit_prediction always appears; it forwards to footics-api + // POST /v1/predictions and is gated at call time by MCP_ENABLE_WRITES. mcp.AddTool(server, &mcp.Tool{ Name: "submit_prediction", Title: "Poser un prono", @@ -281,16 +280,39 @@ func (s *toolServer) search(ctx context.Context, _ *mcp.CallToolRequest, a argsS /* ── write tool (stub until M4) ────────────────────────────────────────────── */ -func (s *toolServer) submitPrediction(ctx context.Context, _ *mcp.CallToolRequest, _ argsSubmit) (*mcp.CallToolResult, any, error) { - if _, errRes := s.gate(ctx); errRes != nil { +func (s *toolServer) submitPrediction(ctx context.Context, _ *mcp.CallToolRequest, a argsSubmit) (*mcp.CallToolResult, any, error) { + id, errRes := s.gate(ctx) + if errRes != nil { return errRes, nil, nil } if !s.enableWrites { return jsonErr("L'écriture de pronos via MCP n'est pas encore disponible sur cette instance (MCP_ENABLE_WRITES=false)."), nil, nil } - // Writes enabled, but the write path (footics-api POST /v1/predictions) ships - // with M4 and is not wired here yet. - return jsonErr("L'écriture de pronos via MCP arrivera avec l'API /v1 POST (M4) — non encore câblée sur cette instance."), nil, nil + // footics-api POST /v1/predictions is the single write point + trust boundary: + // it re-verifies the token and enforces every rule (score range, 90' lock, + // joker quota, KO-qualifier normalisation). We forward and surface its verdict. + in := apiclient.SubmitPredictionInput{ + MatchID: a.MatchID, + Home: a.HomeScore, + Away: a.AwayScore, + Joker: a.Joker, + } + if a.WinnerTeamCode != "" { + w := a.WinnerTeamCode + in.WinnerTeamCode = &w + } + body, status, err := s.api.SubmitPrediction(ctx, id.token, in) + if err != nil { + return apiErr("submit_prediction", err), nil, nil + } + if status < 200 || status >= 300 { + // Surface the API's own FR message ({ok:false, error}) verbatim to the model. + if msg, _ := body["error"].(string); msg != "" { + return jsonErr(msg), nil, nil + } + return jsonErr(fmt.Sprintf("Échec de l'enregistrement du prono (HTTP %d).", status)), nil, nil + } + return jsonOK(body), nil, nil } /* ── helpers ───────────────────────────────────────────────────────────────── */ diff --git a/lib/auth.ts b/lib/auth.ts deleted file mode 100644 index 5289aaf..0000000 --- a/lib/auth.ts +++ /dev/null @@ -1,57 +0,0 @@ -import "server-only"; - -import { createClient } from "@supabase/supabase-js"; -import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; -import { MCP_RESOURCE, SUPABASE_ANON_KEY, SUPABASE_URL, TOKEN_VERIFY } from "@/lib/config"; - -export type McpIdentity = { userId: string; email: string | null }; - -let _supabase: ReturnType | null = null; -function supabase() { - _supabase ??= createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { - auth: { persistSession: false, autoRefreshToken: false }, - }); - return _supabase; -} - -async function verifyViaGetUser(token: string): Promise { - const { data, error } = await supabase().auth.getUser(token); - if (error || !data?.user) return null; - return { userId: data.user.id, email: data.user.email ?? null }; -} - -let _jwks: ReturnType | null = null; -function jwks() { - _jwks ??= createRemoteJWKSet(new URL(`${SUPABASE_URL}/auth/v1/.well-known/jwks.json`), { - cacheMaxAge: 600_000, - cooldownDuration: 30_000, - }); - return _jwks; -} - -interface SupabaseClaims extends JWTPayload { - sub: string; - email?: string; - role?: string; -} - -async function verifyViaJwks(token: string): Promise { - const { payload } = await jwtVerify(token, jwks(), { - issuer: `${SUPABASE_URL}/auth/v1`, - audience: ["authenticated", MCP_RESOURCE], - clockTolerance: 5, - }); - const claims = payload as SupabaseClaims; - if (!claims.sub) return null; - return { userId: claims.sub, email: claims.email ?? null }; -} - -export async function verifyToken(token: string | undefined): Promise { - if (!token) return null; - try { - return TOKEN_VERIFY === "jwks" ? await verifyViaJwks(token) : await verifyViaGetUser(token); - } catch (err) { - console.error("[mcp-auth] token verification failed:", err); - return null; - } -} diff --git a/lib/competitions.ts b/lib/competitions.ts deleted file mode 100644 index 156087d..0000000 --- a/lib/competitions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { CompetitionKind } from "@/lib/types"; - -export const WC_SLUG = "wc-2026"; -export const FRIENDLIES_SLUG = "friendlies-2026"; - -const SLUG_BY_KIND: Record = { - wc: WC_SLUG, - friendlies: FRIENDLIES_SLUG, -}; - -const KIND_BY_SLUG: Record = { - [WC_SLUG]: "wc", - [FRIENDLIES_SLUG]: "friendlies", -}; - -export function slugForKind(kind: CompetitionKind): string { - return SLUG_BY_KIND[kind]; -} - -export function kindForSlug(slug: string): CompetitionKind { - return KIND_BY_SLUG[slug] ?? "wc"; -} - -export function isFriendlySlug(slug: string): boolean { - return slug === FRIENDLIES_SLUG; -} diff --git a/lib/config.ts b/lib/config.ts deleted file mode 100644 index 9ea1481..0000000 --- a/lib/config.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const MCP_PUBLIC_URL = (process.env.MCP_PUBLIC_URL ?? "https://mcp.footics.app").replace(/\/$/, ""); - -export const MCP_RESOURCE = `${MCP_PUBLIC_URL}/mcp`; - -export const SUPABASE_URL = (process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL ?? "").replace(/\/$/, ""); -export const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? process.env.SUPABASE_ANON_KEY ?? ""; - -export const MCP_ENABLED = (process.env.MCP_ENABLED ?? "true") !== "false"; - -export const REQUIRE_AUTH = (process.env.MCP_REQUIRE_AUTH ?? "true") !== "false"; - -export const ENABLE_WRITES = (process.env.MCP_ENABLE_WRITES ?? "false") === "true"; - -export const TOKEN_VERIFY = (process.env.MCP_TOKEN_VERIFY ?? "getuser") as "getuser" | "jwks"; - -export const OAUTH_ISSUER = process.env.MCP_OAUTH_ISSUER ?? (SUPABASE_URL ? `${SUPABASE_URL}/auth/v1` : ""); - -export const SCOPE_READ = "footics:read"; -export const SCOPE_WRITE = "footics:pronostics"; - -const rawRateLimit = Number(process.env.MCP_RATE_LIMIT_PER_MIN ?? "30"); -export const RATE_LIMIT_PER_MIN = Number.isFinite(rawRateLimit) ? rawRateLimit : 30; - -export const TEST_USER_ID = process.env.MCP_TEST_USER_ID ?? ""; - -export function configWarnings(): string[] { - const w: string[] = []; - if (!SUPABASE_URL) w.push("SUPABASE_URL/NEXT_PUBLIC_SUPABASE_URL missing — auth will fail."); - if (!SUPABASE_ANON_KEY) w.push("SUPABASE_ANON_KEY/NEXT_PUBLIC_SUPABASE_ANON_KEY missing — auth (getuser) will fail."); - if (!process.env.DATABASE_URL) w.push("DATABASE_URL missing — every query will fail."); - return w; -} diff --git a/lib/jokers.ts b/lib/jokers.ts deleted file mode 100644 index ac99cd7..0000000 --- a/lib/jokers.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { isFriendlySlug } from "@/lib/competitions"; -import type { Phase } from "@/lib/types"; - -export type JokerBucket = "group" | "round_of_32" | "round_of_16" | "quarter_final" | "semi_final" | "final"; - -export function jokerBucket(phase: Phase): JokerBucket { - return phase === "third_place" ? "final" : phase; -} - -export const JOKER_QUOTA: Record = { - group: 12, - round_of_32: 4, - round_of_16: 2, - quarter_final: 1, - semi_final: 1, - final: 1, -}; - -export function jokerQuota(phase: Phase): number { - return JOKER_QUOTA[jokerBucket(phase)]; -} - -const BUCKET_LABEL: Record = { - group: "la phase de poules", - round_of_32: "les 16es de finale", - round_of_16: "les 8es de finale", - quarter_final: "les quarts", - semi_final: "les demies", - final: "la finale", -}; - -export function jokerBucketLabel(phase: Phase): string { - return BUCKET_LABEL[jokerBucket(phase)]; -} - -export const FRIENDLY_BUCKET = "friendly"; -export const FRIENDLY_JOKER_QUOTA = 3; - -export function jokerBucketFor(competitionSlug: string, phase: Phase): string { - return isFriendlySlug(competitionSlug) ? FRIENDLY_BUCKET : jokerBucket(phase); -} - -export function jokerQuotaFor(competitionSlug: string, phase: Phase): number { - return isFriendlySlug(competitionSlug) ? FRIENDLY_JOKER_QUOTA : jokerQuota(phase); -} - -export function jokerBucketLabelFor(competitionSlug: string, phase: Phase): string { - return isFriendlySlug(competitionSlug) ? "les amicaux" : jokerBucketLabel(phase); -} diff --git a/lib/predictions.ts b/lib/predictions.ts deleted file mode 100644 index 6ba36f7..0000000 --- a/lib/predictions.ts +++ /dev/null @@ -1,86 +0,0 @@ -import "server-only"; - -import { and, eq } from "drizzle-orm"; -import { db } from "@/db"; -import { competitions, matches, predictions } from "@/db/schema"; -import { jokerBucketFor, jokerBucketLabelFor, jokerQuotaFor } from "@/lib/jokers"; -import type { Phase } from "@/lib/types"; - -export type SubmitResult = - | { ok: true; saved: { matchId: string; home: number; away: number; joker: boolean; winnerTeamCode: string | null; koDrawNeedsQualifier: boolean }; locked: false } - | { ok: false; error: string }; - -export async function submitPredictionFor( - userId: string, - input: { matchId: string; homeScore: number; awayScore: number; joker: boolean; winnerTeamCode?: string | null }, -): Promise { - const h = Math.trunc(input.homeScore); - const a = Math.trunc(input.awayScore); - if (!(h >= 0 && h <= 20 && a >= 0 && a <= 20)) return { ok: false, error: "Score invalide (0 à 20)." }; - - const [m] = await db - .select({ match: matches, slug: competitions.slug }) - .from(matches) - .innerJoin(competitions, eq(matches.competitionId, competitions.id)) - .where(eq(matches.id, input.matchId)) - .limit(1); - if (!m) return { ok: false, error: "Match introuvable." }; - - if (m.match.status !== "scheduled" || m.match.kickoffAt.getTime() <= Date.now()) { - return { ok: false, error: "Match verrouillé : le coup d'envoi est passé." }; - } - - const bucket = jokerBucketFor(m.slug, m.match.phase as Phase); - if (input.joker) { - const held = await db - .select({ matchId: predictions.matchId, slug: competitions.slug, phase: matches.phase }) - .from(predictions) - .innerJoin(matches, eq(predictions.matchId, matches.id)) - .innerJoin(competitions, eq(matches.competitionId, competitions.id)) - .where(and(eq(predictions.userId, userId), eq(predictions.jokerApplied, true))); - const usedElsewhere = held.filter((j) => jokerBucketFor(j.slug, j.phase as Phase) === bucket && j.matchId !== m.match.id).length; - if (usedElsewhere >= jokerQuotaFor(m.slug, m.match.phase as Phase)) { - return { ok: false, error: `Plus de joker disponible pour ${jokerBucketLabelFor(m.slug, m.match.phase as Phase)} : retires-en un d'abord.` }; - } - } - - // Qualifié (nouvelle règle KO) : n'a de sens que sur un NUL prédit en match à - // élimination. On NORMALISE pour ne jamais violer le CHECK predictions_winner_implies_draw : - // - hors nul KO → winner_team_code = null. C'ÉTAIT LE BUG : passer un nul KO déjà - // qualifié (winner posé via le web) à un score décisif laissait le qualifié périmé → - // winner ≠ null + home ≠ away → violation du CHECK. On l'efface désormais. - // - nul KO → on pose le qualifié fourni (validé ∈ {dom, ext}), sinon on PRÉSERVE celui - // déjà choisi (ex. via le web), pour ne pas le perdre en éditant juste le score. - const isKoDraw = m.match.scoringRule === "knockout" && h === a; - const validWinner = - isKoDraw && input.winnerTeamCode && (input.winnerTeamCode === m.match.homeTeamCode || input.winnerTeamCode === m.match.awayTeamCode) - ? input.winnerTeamCode - : null; - let winnerTeamCode: string | null = null; - if (isKoDraw) { - if (input.winnerTeamCode !== undefined) { - winnerTeamCode = validWinner; - } else { - const [existing] = await db - .select({ w: predictions.winnerTeamCode }) - .from(predictions) - .where(and(eq(predictions.userId, userId), eq(predictions.matchId, m.match.id))) - .limit(1); - winnerTeamCode = existing?.w ?? null; - } - } - - await db - .insert(predictions) - .values({ userId, matchId: m.match.id, homeScore: h, awayScore: a, jokerApplied: input.joker, winnerTeamCode }) - .onConflictDoUpdate({ - target: [predictions.userId, predictions.matchId], - set: { homeScore: h, awayScore: a, jokerApplied: input.joker, winnerTeamCode, updatedAt: new Date() }, - }); - - return { - ok: true, - saved: { matchId: m.match.id, home: h, away: a, joker: input.joker, winnerTeamCode, koDrawNeedsQualifier: isKoDraw && winnerTeamCode == null }, - locked: false, - }; -} diff --git a/lib/queries.ts b/lib/queries.ts deleted file mode 100644 index 3204dcd..0000000 --- a/lib/queries.ts +++ /dev/null @@ -1,408 +0,0 @@ -import "server-only"; - -import { aliasedTable, and, asc, desc, eq, ilike, inArray, or, sql } from "drizzle-orm"; -import { db } from "@/db"; -import { - competitions, - groupMembers, - groups, - matchEvents, - matchLive, - matches, - predictions, - teams, - usernameMatches, - users, - userCompetitionStandings, -} from "@/db/schema"; -import { slugForKind } from "@/lib/competitions"; -import { jokerBucketFor, jokerBucketLabelFor, jokerQuotaFor, type JokerBucket } from "@/lib/jokers"; -import type { CompetitionKind, MatchStatus, Phase } from "@/lib/types"; - -const competitionIdCache = new Map(); - -async function competitionIdForKind(comp: CompetitionKind): Promise { - const slug = slugForKind(comp); - const cached = competitionIdCache.get(slug); - if (cached) return cached; - const [c] = await db.select({ id: competitions.id }).from(competitions).where(eq(competitions.slug, slug)).limit(1); - if (!c) throw new Error(`[mcp/queries] no competition found for slug "${slug}" (missing seed?)`); - competitionIdCache.set(slug, c.id); - return c.id; -} - -export type MatchJson = { - id: string; - competition: CompetitionKind; - phase: Phase; - group: string | null; - status: MatchStatus; - kickoffAt: string; - venue: string | null; - home: { code: string; name: string }; - away: { code: string; name: string }; - score: { home: number; away: number } | null; - live: { period: string; minute: number | null; running: boolean } | null; - myPrediction: { home: number; away: number; joker: boolean; points: number | null } | null; -}; - -function kindForSlug(slug: string): CompetitionKind { - return slug === slugForKind("friendlies") ? "friendlies" : "wc"; -} - -interface MatchRow { - id: string; - phase: string; - groupLabel: string | null; - status: string; - kickoffAt: Date; - venue: string | null; - homeCode: string; - awayCode: string; - homeScore: number | null; - awayScore: number | null; - homeName: string; - awayName: string; - slug: string; - pId: string | null; - pHome: number | null; - pAway: number | null; - pJoker: boolean | null; - pPoints: number | null; - livePeriod: string | null; - liveMinute: number | null; - liveRunning: boolean | null; -} - -function toMatchJson(r: MatchRow): MatchJson { - const hasScore = r.homeScore != null && r.awayScore != null; - const isLive = r.status === "live"; - const isFinished = r.status === "finished"; - return { - id: r.id, - competition: kindForSlug(r.slug), - phase: r.phase as Phase, - group: r.groupLabel ?? null, - status: r.status as MatchStatus, - kickoffAt: r.kickoffAt.toISOString(), - venue: r.venue ?? null, - home: { code: r.homeCode, name: r.homeName }, - away: { code: r.awayCode, name: r.awayName }, - score: (isLive || isFinished) && hasScore ? { home: r.homeScore as number, away: r.awayScore as number } : null, - live: isLive && r.livePeriod ? { period: r.livePeriod, minute: r.liveMinute ?? null, running: r.liveRunning ?? false } : null, - myPrediction: r.pId != null ? { home: r.pHome as number, away: r.pAway as number, joker: r.pJoker as boolean, points: r.pPoints ?? null } : null, - }; -} - -function matchSelect(userId: string) { - const homeT = aliasedTable(teams, "home_t"); - const awayT = aliasedTable(teams, "away_t"); - return db - .select({ - id: matches.id, - phase: matches.phase, - groupLabel: matches.groupLabel, - status: matches.status, - kickoffAt: matches.kickoffAt, - venue: matches.venue, - homeCode: matches.homeTeamCode, - awayCode: matches.awayTeamCode, - homeScore: matches.homeScore, - awayScore: matches.awayScore, - homeName: homeT.name, - awayName: awayT.name, - slug: competitions.slug, - pId: predictions.id, - pHome: predictions.homeScore, - pAway: predictions.awayScore, - pJoker: predictions.jokerApplied, - pPoints: predictions.pointsAwarded, - livePeriod: matchLive.period, - liveMinute: matchLive.displayMinute, - liveRunning: matchLive.running, - }) - .from(matches) - .innerJoin(competitions, eq(matches.competitionId, competitions.id)) - .innerJoin(homeT, eq(matches.homeTeamCode, homeT.code)) - .innerJoin(awayT, eq(matches.awayTeamCode, awayT.code)) - .leftJoin(predictions, and(eq(predictions.matchId, matches.id), eq(predictions.userId, userId))) - .leftJoin(matchLive, eq(matchLive.matchId, matches.id)); -} - -export async function listMatches( - userId: string, - comp: CompetitionKind, - opts: { status?: MatchStatus; limit?: number } = {}, -): Promise { - const competitionId = await competitionIdForKind(comp); - const rows = await matchSelect(userId) - .where(and(eq(matches.competitionId, competitionId), opts.status ? eq(matches.status, opts.status) : undefined)) - .orderBy(asc(matches.kickoffAt)) - .limit(Math.min(Math.max(opts.limit ?? 200, 1), 200)); - return rows.map((r) => toMatchJson(r)); -} - -export type MatchEventJson = { - type: string; - period: string; - minute: number; - minutePlus: number | null; - teamCode: string | null; - player: string | null; - detail: string | null; -}; - -export async function getMatch( - userId: string, - matchId: string, -): Promise<(MatchJson & { events: MatchEventJson[] }) | null> { - const [row] = await matchSelect(userId).where(eq(matches.id, matchId)).limit(1); - if (!row) return null; - const base = toMatchJson(row); - const events = - base.status === "scheduled" - ? [] - : ( - await db - .select() - .from(matchEvents) - .where(eq(matchEvents.matchId, matchId)) - .orderBy(asc(matchEvents.minute), asc(matchEvents.sortOrder), asc(matchEvents.createdAt)) - ).map((e) => ({ - type: e.type, - period: e.period, - minute: e.minute, - minutePlus: e.minutePlus ?? null, - teamCode: e.teamCode ?? null, - player: e.player ?? null, - detail: e.detail ?? null, - })); - return { ...base, events }; -} - -export type StandingJson = { - userId: string; - username: string; - competition: CompetitionKind; - points: number; - exact: number; - rank: number; - rankOf: number; -}; - -export async function getMyStanding(userId: string, comp: CompetitionKind): Promise { - const competitionId = await competitionIdForKind(comp); - const [u] = await db.select().from(users).where(eq(users.id, userId)).limit(1); - if (!u) return null; - - const [mine] = await db - .select({ points: userCompetitionStandings.pointsTotal, exact: userCompetitionStandings.exactCount }) - .from(userCompetitionStandings) - .where(and(eq(userCompetitionStandings.userId, userId), eq(userCompetitionStandings.competitionId, competitionId))) - .limit(1); - const points = mine?.points ?? 0; - const exact = mine?.exact ?? 0; - - const [{ ahead }] = await db - .select({ ahead: sql`count(*)::int` }) - .from(userCompetitionStandings) - .where(and(eq(userCompetitionStandings.competitionId, competitionId), sql`${userCompetitionStandings.pointsTotal} > ${points}`)); - const [{ total }] = await db.select({ total: sql`count(*)::int` }).from(users); - - return { userId: u.id, username: u.username, competition: comp, points, exact, rank: ahead + 1, rankOf: total }; -} - -export type PredictionJson = { - matchId: string; - fixture: string; - kickoffAt: string; - status: MatchStatus; - prediction: { home: number; away: number; joker: boolean }; - result: { home: number; away: number } | null; - points: number | null; -}; - -export async function getMyPredictions( - userId: string, - comp: CompetitionKind, - opts: { when?: "upcoming" | "past" | "all"; limit?: number } = {}, -): Promise { - const competitionId = await competitionIdForKind(comp); - const when = opts.when ?? "all"; - const statusCond = - when === "upcoming" ? eq(matches.status, "scheduled") : when === "past" ? eq(matches.status, "finished") : undefined; - const rows = await db - .select({ - matchId: matches.id, - home: matches.homeTeamCode, - away: matches.awayTeamCode, - kickoffAt: matches.kickoffAt, - status: matches.status, - pHome: predictions.homeScore, - pAway: predictions.awayScore, - joker: predictions.jokerApplied, - points: predictions.pointsAwarded, - mHome: matches.homeScore, - mAway: matches.awayScore, - }) - .from(predictions) - .innerJoin(matches, eq(predictions.matchId, matches.id)) - .where(and(eq(predictions.userId, userId), eq(matches.competitionId, competitionId), statusCond)) - .orderBy(when === "upcoming" ? asc(matches.kickoffAt) : desc(matches.kickoffAt)) - .limit(Math.min(Math.max(opts.limit ?? 100, 1), 200)); - return rows.map((r) => ({ - matchId: r.matchId, - fixture: `${r.home}-${r.away}`, - kickoffAt: r.kickoffAt.toISOString(), - status: r.status as MatchStatus, - prediction: { home: r.pHome, away: r.pAway, joker: r.joker }, - result: r.status === "finished" && r.mHome != null && r.mAway != null ? { home: r.mHome, away: r.mAway } : null, - points: r.points ?? null, - })); -} - -export type LeaderRowJson = { rank: number; username: string; points: number; exact: number; me: boolean }; - -const boardOrder = [ - desc(sql`coalesce(${userCompetitionStandings.pointsTotal}, 0)`), - desc(sql`coalesce(${userCompetitionStandings.exactCount}, 0)`), - asc(users.createdAt), -]; - -export async function getLeaderboard( - userId: string, - comp: CompetitionKind, - opts: { groupId?: string; limit?: number } = {}, -): Promise { - const competitionId = await competitionIdForKind(comp); - const base = db - .select({ - id: users.id, - name: users.username, - pts: sql`coalesce(${userCompetitionStandings.pointsTotal}, 0)::int`, - exact: sql`coalesce(${userCompetitionStandings.exactCount}, 0)::int`, - }) - .from(users) - .leftJoin( - userCompetitionStandings, - and(eq(userCompetitionStandings.userId, users.id), eq(userCompetitionStandings.competitionId, competitionId)), - ); - - const rows = opts.groupId - ? await base - .innerJoin(groupMembers, eq(groupMembers.userId, users.id)) - .where(eq(groupMembers.groupId, opts.groupId)) - .orderBy(...boardOrder) - .limit(Math.min(Math.max(opts.limit ?? 50, 1), 200)) - : await base.orderBy(...boardOrder).limit(Math.min(Math.max(opts.limit ?? 50, 1), 200)); - - return rows.map((r, i) => ({ rank: i + 1, username: r.name, points: r.pts, exact: r.exact, me: r.id === userId })); -} - -export type GroupJson = { id: string; name: string; code: string; members: number; myRank: number; myPoints: number; owner: boolean }; - -export async function listMyGroups(userId: string, comp: CompetitionKind): Promise { - const competitionId = await competitionIdForKind(comp); - const mine = await db.select({ g: groups }).from(groupMembers).innerJoin(groups, eq(groupMembers.groupId, groups.id)).where(eq(groupMembers.userId, userId)); - const out: GroupJson[] = []; - for (const { g } of mine) { - const board = await db - .select({ id: users.id, pts: sql`coalesce(${userCompetitionStandings.pointsTotal}, 0)::int` }) - .from(groupMembers) - .innerJoin(users, eq(groupMembers.userId, users.id)) - .leftJoin( - userCompetitionStandings, - and(eq(userCompetitionStandings.userId, users.id), eq(userCompetitionStandings.competitionId, competitionId)), - ) - .where(eq(groupMembers.groupId, g.id)) - .orderBy(...boardOrder); - const idx = board.findIndex((r) => r.id === userId); - out.push({ - id: g.id, - name: g.name, - code: g.inviteCode, - members: board.length, - myRank: idx >= 0 ? idx + 1 : board.length, - myPoints: idx >= 0 ? board[idx].pts : 0, - owner: g.ownerId === userId, - }); - } - return out; -} - -export type JokerStatusJson = { bucket: string; label: string; used: number; quota: number; remaining: number }; - -export async function getJokerStatus(userId: string, comp: CompetitionKind): Promise { - const competitionId = await competitionIdForKind(comp); - const held = await db - .select({ slug: competitions.slug, phase: matches.phase }) - .from(predictions) - .innerJoin(matches, eq(predictions.matchId, matches.id)) - .innerJoin(competitions, eq(matches.competitionId, competitions.id)) - .where(and(eq(predictions.userId, userId), eq(predictions.jokerApplied, true), eq(matches.competitionId, competitionId))); - - const phases = await db - .select({ slug: competitions.slug, phase: matches.phase }) - .from(matches) - .innerJoin(competitions, eq(matches.competitionId, competitions.id)) - .where(eq(matches.competitionId, competitionId)) - .groupBy(competitions.slug, matches.phase); - - const used = new Map(); - for (const h of held) { - const b = jokerBucketFor(h.slug, h.phase as Phase); - used.set(b, (used.get(b) ?? 0) + 1); - } - - const seen = new Map(); - for (const p of phases) { - const bucket = jokerBucketFor(p.slug, p.phase as Phase); - if (!seen.has(bucket)) { - seen.set(bucket, { label: jokerBucketLabelFor(p.slug, p.phase as Phase), quota: jokerQuotaFor(p.slug, p.phase as Phase) }); - } - } - - return [...seen.entries()].map(([bucket, info]) => { - const u = used.get(bucket) ?? 0; - return { bucket, label: info.label, used: u, quota: info.quota, remaining: Math.max(info.quota - u, 0) }; - }); -} - -export type SearchJson = { - matches: { id: string; fixture: string; kickoffAt: string }[]; - groups: { id: string; name: string }[]; - users: { id: string; username: string; me: boolean }[]; -}; - -export async function search(userId: string, query: string): Promise { - const q = query.trim(); - if (q.length < 1) return { matches: [], groups: [], users: [] }; - const like = `%${q.replace(/[%_]/g, (c) => `\\${c}`)}%`; - const PER = 8; - const homeT = aliasedTable(teams, "home_t"); - const awayT = aliasedTable(teams, "away_t"); - - const [matchRows, groupRows, userRows] = await Promise.all([ - db - .select({ id: matches.id, home: matches.homeTeamCode, away: matches.awayTeamCode, kickoffAt: matches.kickoffAt }) - .from(matches) - .innerJoin(homeT, eq(matches.homeTeamCode, homeT.code)) - .innerJoin(awayT, eq(matches.awayTeamCode, awayT.code)) - .where(or(ilike(homeT.name, like), ilike(awayT.name, like), ilike(matches.homeTeamCode, like), ilike(matches.awayTeamCode, like), ilike(matches.groupLabel, like))) - .orderBy(asc(matches.kickoffAt)) - .limit(PER), - db - .select({ g: groups }) - .from(groupMembers) - .innerJoin(groups, eq(groupMembers.groupId, groups.id)) - .where(and(eq(groupMembers.userId, userId), ilike(groups.name, like))) - .limit(PER), - db.select().from(users).where(ilike(users.username, like)).orderBy(asc(users.username)).limit(PER), - ]); - - return { - matches: matchRows.map((m) => ({ id: m.id, fixture: `${m.home}-${m.away}`, kickoffAt: m.kickoffAt.toISOString() })), - groups: groupRows.map(({ g }) => ({ id: g.id, name: g.name })), - users: userRows.map((u) => ({ id: u.id, username: u.username, me: u.id === userId })), - }; -} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts deleted file mode 100644 index 7f8a3da..0000000 --- a/lib/rate-limit.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { RATE_LIMIT_PER_MIN } from "@/lib/config"; - -const WINDOW_MS = 60_000; -const MAX_ENTRIES = 10_000; - -type Window = { startedAt: number; count: number }; -const windows = new Map(); - -export function rateLimit(userId: string, now = Date.now()): number { - if (RATE_LIMIT_PER_MIN <= 0) return 0; - const w = windows.get(userId); - if (!w || now - w.startedAt >= WINDOW_MS) { - if (windows.size >= MAX_ENTRIES) prune(now); - windows.set(userId, { startedAt: now, count: 1 }); - return 0; - } - if (w.count < RATE_LIMIT_PER_MIN) { - w.count += 1; - return 0; - } - return Math.max(1, Math.ceil((w.startedAt + WINDOW_MS - now) / 1000)); -} - -function prune(now: number): void { - for (const [key, w] of windows) { - if (now - w.startedAt >= WINDOW_MS) windows.delete(key); - } - if (windows.size >= MAX_ENTRIES) windows.clear(); -} diff --git a/lib/tools.ts b/lib/tools.ts deleted file mode 100644 index 7c508eb..0000000 --- a/lib/tools.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { ENABLE_WRITES, RATE_LIMIT_PER_MIN, TEST_USER_ID } from "@/lib/config"; -import { rateLimit } from "@/lib/rate-limit"; -import { - getJokerStatus, - getLeaderboard, - getMatch, - getMyPredictions, - getMyStanding, - listMatches, - listMyGroups, - search, -} from "@/lib/queries"; -import { submitPredictionFor } from "@/lib/predictions"; - -type ToolExtra = { authInfo?: { extra?: Record } }; -type ToolResult = { content: { type: "text"; text: string }[]; isError?: boolean }; - -const COMP = z.enum(["wc", "friendlies"]).default("wc").describe('Compétition : "wc" (Coupe du monde) ou "friendlies" (amicaux). Défaut: wc.'); - -function jsonOk(data: unknown): ToolResult { - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; -} -function jsonErr(message: string): ToolResult { - return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true }; -} - -function identity(extra: ToolExtra): { userId: string; email: string | null } | null { - const a = extra?.authInfo?.extra as { userId?: string; email?: string | null } | undefined; - if (a?.userId) return { userId: a.userId, email: a.email ?? null }; - if (TEST_USER_ID) return { userId: TEST_USER_ID, email: null }; - return null; -} - -function gate(extra: ToolExtra): { me: { userId: string; email: string | null } } | { err: ToolResult } { - const me = identity(extra); - if (!me) return { err: jsonErr("Non authentifié.") }; - const retryInSec = rateLimit(me.userId); - if (retryInSec > 0) { - return { err: jsonErr(`Limite de débit atteinte (${RATE_LIMIT_PER_MIN} appels/min) — attends ~${retryInSec}s avant de réessayer.`) }; - } - return { me }; -} - -export function registerTools(server: McpServer): void { - server.registerTool( - "whoami", - { title: "Qui suis-je", description: "Renvoie l'identité Footics de l'utilisateur connecté (id, email). Utile pour vérifier la connexion.", inputSchema: {} }, - async (_args, extra) => { - const g = gate(extra); - if ("err" in g) return g.err; - const me = g.me; - return jsonOk({ userId: me.userId, email: me.email }); - }, - ); - - server.registerTool( - "list_matches", - { - title: "Lister les matchs", - description: - "Liste les matchs d'une compétition (avec mon prono, le statut, le score live/final). Filtrable par statut. Pour voir le calendrier, les matchs à venir, en cours ou terminés.", - inputSchema: { - competition: COMP, - status: z.enum(["scheduled", "live", "finished", "postponed", "cancelled"]).optional().describe("Filtre de statut optionnel."), - limit: z.number().int().min(1).max(200).optional().describe("Nombre max de matchs (défaut 200)."), - }, - }, - async (args, extra) => { - const g = gate(extra); - if ("err" in g) return g.err; - const me = g.me; - const a = args as { competition: "wc" | "friendlies"; status?: never; limit?: number }; - return jsonOk(await listMatches(me.userId, a.competition, { status: args.status as never, limit: a.limit })); - }, - ); - - server.registerTool( - "get_match", - { - title: "Détail d'un match", - description: "Renvoie un match par son id : équipes, coup d'envoi, statut, score, timeline des buts/cartons (si commencé) et mon prono.", - inputSchema: { matchId: z.string().min(1).describe("L'id (uuid) du match.") }, - }, - async (args, extra) => { - const g = gate(extra); - if ("err" in g) return g.err; - const me = g.me; - const match = await getMatch(me.userId, String(args.matchId)); - return match ? jsonOk(match) : jsonErr("Match introuvable."); - }, - ); - - server.registerTool( - "get_my_standing", - { - title: "Mon classement", - description: "Mes points, scores exacts, mon rang et le nombre total de joueurs pour une compétition.", - inputSchema: { competition: COMP }, - }, - async (args, extra) => { - const g = gate(extra); - if ("err" in g) return g.err; - const me = g.me; - const s = await getMyStanding(me.userId, args.competition as "wc" | "friendlies"); - return s ? jsonOk(s) : jsonErr("Profil introuvable."); - }, - ); - - server.registerTool( - "get_my_predictions", - { - title: "Mes pronos", - description: "Mes pronostics pour une compétition. `when` = upcoming (à venir), past (terminés) ou all (tous, défaut).", - inputSchema: { - competition: COMP, - when: z.enum(["upcoming", "past", "all"]).default("all"), - limit: z.number().int().min(1).max(200).optional(), - }, - }, - async (args, extra) => { - const g = gate(extra); - if ("err" in g) return g.err; - const me = g.me; - const a = args as { competition: "wc" | "friendlies"; when: "upcoming" | "past" | "all"; limit?: number }; - return jsonOk(await getMyPredictions(me.userId, a.competition, { when: a.when, limit: a.limit })); - }, - ); - - server.registerTool( - "get_leaderboard", - { - title: "Classement", - description: "Classement général d'une compétition, ou d'un de mes groupes (via groupId). Trié par points puis scores exacts.", - inputSchema: { - competition: COMP, - groupId: z.string().optional().describe("Id d'un groupe dont je suis membre (sinon classement général)."), - limit: z.number().int().min(1).max(200).optional(), - }, - }, - async (args, extra) => { - const g = gate(extra); - if ("err" in g) return g.err; - const me = g.me; - const a = args as { competition: "wc" | "friendlies"; groupId?: string; limit?: number }; - return jsonOk(await getLeaderboard(me.userId, a.competition, { groupId: a.groupId, limit: a.limit })); - }, - ); - - server.registerTool( - "list_my_groups", - { - title: "Mes groupes", - description: "Les groupes dont je suis membre, avec mon rang et mes points dans chacun pour la compétition donnée.", - inputSchema: { competition: COMP }, - }, - async (args, extra) => { - const g = gate(extra); - if ("err" in g) return g.err; - const me = g.me; - return jsonOk(await listMyGroups(me.userId, args.competition as "wc" | "friendlies")); - }, - ); - - server.registerTool( - "get_joker_status", - { - title: "Mes jokers", - description: "Mon stock de jokers par bucket (poules, 8es, …) pour une compétition : utilisés / quota / restants. Le joker double les points d'un match.", - inputSchema: { competition: COMP }, - }, - async (args, extra) => { - const g = gate(extra); - if ("err" in g) return g.err; - const me = g.me; - return jsonOk(await getJokerStatus(me.userId, args.competition as "wc" | "friendlies")); - }, - ); - - server.registerTool( - "search", - { - title: "Rechercher", - description: "Recherche transverse : matchs (par équipe/poule), mes groupes (par nom), joueurs (par pseudo).", - inputSchema: { query: z.string().min(1).describe("Texte recherché.") }, - }, - async (args, extra) => { - const g = gate(extra); - if ("err" in g) return g.err; - const me = g.me; - return jsonOk(await search(me.userId, String(args.query))); - }, - ); - - if (!ENABLE_WRITES) return; - server.registerTool( - "submit_prediction", - { - title: "Poser un prono", - description: - "Pose ou modifie MON pronostic sur un match (scores 0-20, joker optionnel). Refusé si le coup d'envoi est passé ou si je n'ai plus de joker pour ce bucket. Sur un match à élimination directe : le prono porte sur le score à la fin du temps réglementaire (90'), et si tu prédis un NUL, précise winnerTeamCode (l'équipe qui se qualifie) pour le +1 bonus. Confirme toujours avec l'utilisateur avant d'écrire.", - inputSchema: { - matchId: z.string().min(1).describe("L'id (uuid) du match — voir list_matches/search."), - homeScore: z.number().int().min(0).max(20).describe("Score prédit de l'équipe à domicile (0-20)."), - awayScore: z.number().int().min(0).max(20).describe("Score prédit de l'équipe à l'extérieur (0-20)."), - joker: z.boolean().default(false).describe("Appliquer un joker (double les points). Défaut: false."), - winnerTeamCode: z - .string() - .min(2) - .max(3) - .optional() - .describe("Match à élimination directe + nul prédit UNIQUEMENT : code FIFA-3 de l'équipe qui se qualifie (pour le +1). Doit être l'une des 2 équipes. Ignoré sur un score décisif ou en phase de poules."), - }, - }, - async (args, extra) => { - const g = gate(extra); - if ("err" in g) return g.err; - const me = g.me; - if (!ENABLE_WRITES) { - return jsonErr("L'écriture de pronos via MCP est désactivée sur ce serveur (MCP_ENABLE_WRITES=false)."); - } - const a = args as { matchId: string; homeScore: number; awayScore: number; joker: boolean; winnerTeamCode?: string }; - const res = await submitPredictionFor(me.userId, { matchId: a.matchId, homeScore: a.homeScore, awayScore: a.awayScore, joker: a.joker, winnerTeamCode: a.winnerTeamCode }); - if (!res.ok) return jsonErr(res.error); - // Nul prédit en KO sans qualifié → on invite à le préciser (sinon pas de +1). - const note = res.saved.koDrawNeedsQualifier - ? "Nul prédit sur un match à élimination directe : appelle à nouveau submit_prediction avec winnerTeamCode (l'équipe qui se qualifie) pour activer le +1 bonus." - : undefined; - return jsonOk(note ? { ok: true, saved: res.saved, note } : { ok: true, saved: res.saved }); - }, - ); -} diff --git a/lib/types.ts b/lib/types.ts deleted file mode 100644 index a0f8853..0000000 --- a/lib/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type Phase = - | "group" - | "round_of_32" - | "round_of_16" - | "quarter_final" - | "semi_final" - | "third_place" - | "final"; - -export type MatchStatus = "scheduled" | "live" | "finished" | "postponed" | "cancelled"; - -export type ScoringRule = "simple" | "knockout"; - -export type MatchPeriod = - | "pre" - | "first_half" - | "half_time" - | "second_half" - | "et_break" - | "et_first" - | "et_second" - | "penalties" - | "full_time"; - -export type CompetitionKind = "wc" | "friendlies"; diff --git a/next.config.ts b/next.config.ts deleted file mode 100644 index ae5894e..0000000 --- a/next.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - turbopack: { root: process.cwd() }, - - serverExternalPackages: ["postgres"], - - async rewrites() { - return [ - { source: "/.well-known/oauth-protected-resource/mcp", destination: "/api/oauth-prm" }, - { source: "/.well-known/oauth-protected-resource", destination: "/api/oauth-prm" }, - ]; - }, -}; - -export default nextConfig; diff --git a/package.json b/package.json deleted file mode 100644 index ae3f1af..0000000 --- a/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "footics-mcp", - "version": "0.1.0", - "private": true, - "description": "Remote MCP server for Footics — connect an AI assistant to your World Cup prediction account.", - "license": "MIT", - "author": "Tom Cardoen", - "homepage": "https://mcp.footics.app", - "repository": { - "type": "git", - "url": "git+https://github.com/Footics/mcp-server.git" - }, - "packageManager": "pnpm@10.2.1", - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "typecheck": "tsc --noEmit", - "token": "dotenv -e .env.local -- tsx scripts/mint-test-token.ts", - "oauth-e2e": "dotenv -e .env.local -- node scripts/oauth-e2e.mjs" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "1.26.0", - "@supabase/supabase-js": "^2.106.2", - "drizzle-orm": "^0.45.2", - "jose": "^5.9.6", - "mcp-handler": "^1.1.0", - "next": "16.2.6", - "postgres": "^3.4.9", - "react": "19.2.4", - "react-dom": "19.2.4", - "server-only": "^0.0.1", - "zod": "^3.25.76" - }, - "devDependencies": { - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "dotenv-cli": "^11.0.0", - "tsx": "^4.22.3", - "typescript": "^5" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 8477504..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,1995 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@modelcontextprotocol/sdk': - specifier: 1.26.0 - version: 1.26.0(zod@3.25.76) - '@supabase/supabase-js': - specifier: ^2.106.2 - version: 2.107.0 - drizzle-orm: - specifier: ^0.45.2 - version: 0.45.2(postgres@3.4.9) - jose: - specifier: ^5.9.6 - version: 5.10.0 - mcp-handler: - specifier: ^1.1.0 - version: 1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) - next: - specifier: 16.2.6 - version: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - postgres: - specifier: ^3.4.9 - version: 3.4.9 - react: - specifier: 19.2.4 - version: 19.2.4 - react-dom: - specifier: 19.2.4 - version: 19.2.4(react@19.2.4) - server-only: - specifier: ^0.0.1 - version: 0.0.1 - zod: - specifier: ^3.25.76 - version: 3.25.76 - devDependencies: - '@types/node': - specifier: ^20 - version: 20.19.42 - '@types/react': - specifier: ^19 - version: 19.2.17 - '@types/react-dom': - specifier: ^19 - version: 19.2.3(@types/react@19.2.17) - dotenv-cli: - specifier: ^11.0.0 - version: 11.0.0 - tsx: - specifier: ^4.22.3 - version: 4.22.4 - typescript: - specifier: ^5 - version: 5.9.3 - -packages: - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@modelcontextprotocol/sdk@1.26.0': - resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true - - '@next/env@16.2.6': - resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} - - '@next/swc-darwin-arm64@16.2.6': - resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@next/swc-darwin-x64@16.2.6': - resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@next/swc-linux-arm64-gnu@16.2.6': - resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@next/swc-linux-arm64-musl@16.2.6': - resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@next/swc-linux-x64-gnu@16.2.6': - resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@next/swc-linux-x64-musl@16.2.6': - resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@next/swc-win32-arm64-msvc@16.2.6': - resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@next/swc-win32-x64-msvc@16.2.6': - resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@redis/bloom@1.2.0': - resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} - peerDependencies: - '@redis/client': ^1.0.0 - - '@redis/client@1.6.1': - resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} - engines: {node: '>=14'} - - '@redis/graph@1.1.1': - resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} - peerDependencies: - '@redis/client': ^1.0.0 - - '@redis/json@1.0.7': - resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} - peerDependencies: - '@redis/client': ^1.0.0 - - '@redis/search@1.2.0': - resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} - peerDependencies: - '@redis/client': ^1.0.0 - - '@redis/time-series@1.1.0': - resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} - peerDependencies: - '@redis/client': ^1.0.0 - - '@supabase/auth-js@2.107.0': - resolution: {integrity: sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==} - engines: {node: '>=20.0.0'} - - '@supabase/functions-js@2.107.0': - resolution: {integrity: sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==} - engines: {node: '>=20.0.0'} - - '@supabase/phoenix@0.4.2': - resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==} - - '@supabase/postgrest-js@2.107.0': - resolution: {integrity: sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==} - engines: {node: '>=20.0.0'} - - '@supabase/realtime-js@2.107.0': - resolution: {integrity: sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==} - engines: {node: '>=20.0.0'} - - '@supabase/storage-js@2.107.0': - resolution: {integrity: sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==} - engines: {node: '>=20.0.0'} - - '@supabase/supabase-js@2.107.0': - resolution: {integrity: sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==} - engines: {node: '>=20.0.0'} - - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - - '@types/node@20.19.42': - resolution: {integrity: sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - - baseline-browser-mapping@2.10.34: - resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==} - engines: {node: '>=6.0.0'} - hasBin: true - - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} - engines: {node: '>=18'} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - caniuse-lite@1.0.30001797: - resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - - cluster-key-slot@1.1.2: - resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} - engines: {node: '>=0.10.0'} - - commander@11.1.0: - resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} - engines: {node: '>=16'} - - content-disposition@1.1.0: - resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} - engines: {node: '>=18'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} - engines: {node: '>=18'} - - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - dotenv-cli@11.0.0: - resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==} - hasBin: true - - dotenv-expand@12.0.3: - resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} - engines: {node: '>=12'} - - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} - - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - - drizzle-orm@0.45.2: - resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} - peerDependencies: - '@aws-sdk/client-rds-data': '>=3' - '@cloudflare/workers-types': '>=4' - '@electric-sql/pglite': '>=0.2.0' - '@libsql/client': '>=0.10.0' - '@libsql/client-wasm': '>=0.10.0' - '@neondatabase/serverless': '>=0.10.0' - '@op-engineering/op-sqlite': '>=2' - '@opentelemetry/api': ^1.4.1 - '@planetscale/database': '>=1.13' - '@prisma/client': '*' - '@tidbcloud/serverless': '*' - '@types/better-sqlite3': '*' - '@types/pg': '*' - '@types/sql.js': '*' - '@upstash/redis': '>=1.34.7' - '@vercel/postgres': '>=0.8.0' - '@xata.io/client': '*' - better-sqlite3: '>=7' - bun-types: '*' - expo-sqlite: '>=14.0.0' - gel: '>=2' - knex: '*' - kysely: '*' - mysql2: '>=2' - pg: '>=8' - postgres: '>=3' - prisma: '*' - sql.js: '>=1' - sqlite3: '>=5' - peerDependenciesMeta: - '@aws-sdk/client-rds-data': - optional: true - '@cloudflare/workers-types': - optional: true - '@electric-sql/pglite': - optional: true - '@libsql/client': - optional: true - '@libsql/client-wasm': - optional: true - '@neondatabase/serverless': - optional: true - '@op-engineering/op-sqlite': - optional: true - '@opentelemetry/api': - optional: true - '@planetscale/database': - optional: true - '@prisma/client': - optional: true - '@tidbcloud/serverless': - optional: true - '@types/better-sqlite3': - optional: true - '@types/pg': - optional: true - '@types/sql.js': - optional: true - '@upstash/redis': - optional: true - '@vercel/postgres': - optional: true - '@xata.io/client': - optional: true - better-sqlite3: - optional: true - bun-types: - optional: true - expo-sqlite: - optional: true - gel: - optional: true - knex: - optional: true - kysely: - optional: true - mysql2: - optional: true - pg: - optional: true - postgres: - optional: true - prisma: - optional: true - sql.js: - optional: true - sqlite3: - optional: true - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.2: - resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} - engines: {node: '>= 0.4'} - - esbuild@0.28.0: - resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} - engines: {node: '>=18'} - hasBin: true - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - eventsource-parser@3.1.0: - resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} - engines: {node: '>=18.0.0'} - - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - - express-rate-limit@8.5.2: - resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} - engines: {node: '>= 18'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.0.0'} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - generic-pool@3.9.0: - resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} - engines: {node: '>= 4'} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - hasown@2.0.4: - resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} - engines: {node: '>= 0.4'} - - hono@4.12.23: - resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} - engines: {node: '>=16.9.0'} - - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - - iceberg-js@0.8.1: - resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} - engines: {node: '>=20.0.0'} - - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} - engines: {node: '>= 12'} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - - is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jose@5.10.0: - resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - - jose@6.2.3: - resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema-typed@8.0.2: - resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mcp-handler@1.1.0: - resolution: {integrity: sha512-MVCES7g18gcoZy+R/3v5nadkUMzMAWdos8jRl6DyljOKvd2/ZKDmwlCjL6zp4vo+7FeCXOYL1uWinHWlkKAAUg==} - hasBin: true - peerDependencies: - '@modelcontextprotocol/sdk': 1.26.0 - next: '>=13.0.0' - peerDependenciesMeta: - next: - optional: true - - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - - merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} - engines: {node: '>=18'} - - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - - next@16.2.6: - resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} - engines: {node: '>=20.9.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-to-regexp@8.4.2: - resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - - postgres@3.4.9: - resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} - engines: {node: '>=12'} - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - qs@6.15.2: - resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} - engines: {node: '>=0.6'} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} - - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} - peerDependencies: - react: ^19.2.4 - - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} - engines: {node: '>=0.10.0'} - - redis@4.7.1: - resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@7.8.2: - resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==} - engines: {node: '>=10'} - hasBin: true - - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - - server-only@0.0.1: - resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - styled-jsx@5.1.6: - resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tsx@4.22.4: - resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} - engines: {node: '>=18.0.0'} - hasBin: true - - type-is@2.1.0: - resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} - engines: {node: '>= 18'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - -snapshots: - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.28.0': - optional: true - - '@esbuild/android-arm64@0.28.0': - optional: true - - '@esbuild/android-arm@0.28.0': - optional: true - - '@esbuild/android-x64@0.28.0': - optional: true - - '@esbuild/darwin-arm64@0.28.0': - optional: true - - '@esbuild/darwin-x64@0.28.0': - optional: true - - '@esbuild/freebsd-arm64@0.28.0': - optional: true - - '@esbuild/freebsd-x64@0.28.0': - optional: true - - '@esbuild/linux-arm64@0.28.0': - optional: true - - '@esbuild/linux-arm@0.28.0': - optional: true - - '@esbuild/linux-ia32@0.28.0': - optional: true - - '@esbuild/linux-loong64@0.28.0': - optional: true - - '@esbuild/linux-mips64el@0.28.0': - optional: true - - '@esbuild/linux-ppc64@0.28.0': - optional: true - - '@esbuild/linux-riscv64@0.28.0': - optional: true - - '@esbuild/linux-s390x@0.28.0': - optional: true - - '@esbuild/linux-x64@0.28.0': - optional: true - - '@esbuild/netbsd-arm64@0.28.0': - optional: true - - '@esbuild/netbsd-x64@0.28.0': - optional: true - - '@esbuild/openbsd-arm64@0.28.0': - optional: true - - '@esbuild/openbsd-x64@0.28.0': - optional: true - - '@esbuild/openharmony-arm64@0.28.0': - optional: true - - '@esbuild/sunos-x64@0.28.0': - optional: true - - '@esbuild/win32-arm64@0.28.0': - optional: true - - '@esbuild/win32-ia32@0.28.0': - optional: true - - '@esbuild/win32-x64@0.28.0': - optional: true - - '@hono/node-server@1.19.14(hono@4.12.23)': - dependencies: - hono: 4.12.23 - - '@img/colour@1.1.0': - optional: true - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.10.0 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': - dependencies: - '@hono/node-server': 1.19.14(hono@4.12.23) - ajv: 8.20.0 - ajv-formats: 3.0.1(ajv@8.20.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.1.0 - express: 5.2.1 - express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.23 - jose: 6.2.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - transitivePeerDependencies: - - supports-color - - '@next/env@16.2.6': {} - - '@next/swc-darwin-arm64@16.2.6': - optional: true - - '@next/swc-darwin-x64@16.2.6': - optional: true - - '@next/swc-linux-arm64-gnu@16.2.6': - optional: true - - '@next/swc-linux-arm64-musl@16.2.6': - optional: true - - '@next/swc-linux-x64-gnu@16.2.6': - optional: true - - '@next/swc-linux-x64-musl@16.2.6': - optional: true - - '@next/swc-win32-arm64-msvc@16.2.6': - optional: true - - '@next/swc-win32-x64-msvc@16.2.6': - optional: true - - '@redis/bloom@1.2.0(@redis/client@1.6.1)': - dependencies: - '@redis/client': 1.6.1 - - '@redis/client@1.6.1': - dependencies: - cluster-key-slot: 1.1.2 - generic-pool: 3.9.0 - yallist: 4.0.0 - - '@redis/graph@1.1.1(@redis/client@1.6.1)': - dependencies: - '@redis/client': 1.6.1 - - '@redis/json@1.0.7(@redis/client@1.6.1)': - dependencies: - '@redis/client': 1.6.1 - - '@redis/search@1.2.0(@redis/client@1.6.1)': - dependencies: - '@redis/client': 1.6.1 - - '@redis/time-series@1.1.0(@redis/client@1.6.1)': - dependencies: - '@redis/client': 1.6.1 - - '@supabase/auth-js@2.107.0': - dependencies: - tslib: 2.8.1 - - '@supabase/functions-js@2.107.0': - dependencies: - tslib: 2.8.1 - - '@supabase/phoenix@0.4.2': {} - - '@supabase/postgrest-js@2.107.0': - dependencies: - tslib: 2.8.1 - - '@supabase/realtime-js@2.107.0': - dependencies: - '@supabase/phoenix': 0.4.2 - tslib: 2.8.1 - - '@supabase/storage-js@2.107.0': - dependencies: - iceberg-js: 0.8.1 - tslib: 2.8.1 - - '@supabase/supabase-js@2.107.0': - dependencies: - '@supabase/auth-js': 2.107.0 - '@supabase/functions-js': 2.107.0 - '@supabase/postgrest-js': 2.107.0 - '@supabase/realtime-js': 2.107.0 - '@supabase/storage-js': 2.107.0 - - '@swc/helpers@0.5.15': - dependencies: - tslib: 2.8.1 - - '@types/node@20.19.42': - dependencies: - undici-types: 6.21.0 - - '@types/react-dom@19.2.3(@types/react@19.2.17)': - dependencies: - '@types/react': 19.2.17 - - '@types/react@19.2.17': - dependencies: - csstype: 3.2.3 - - accepts@2.0.0: - dependencies: - mime-types: 3.0.2 - negotiator: 1.0.0 - - ajv-formats@3.0.1(ajv@8.20.0): - optionalDependencies: - ajv: 8.20.0 - - ajv@8.20.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - baseline-browser-mapping@2.10.34: {} - - body-parser@2.2.2: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.15.2 - raw-body: 3.0.2 - type-is: 2.1.0 - transitivePeerDependencies: - - supports-color - - bytes@3.1.2: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - caniuse-lite@1.0.30001797: {} - - chalk@5.6.2: {} - - client-only@0.0.1: {} - - cluster-key-slot@1.1.2: {} - - commander@11.1.0: {} - - content-disposition@1.1.0: {} - - content-type@1.0.5: {} - - content-type@2.0.0: {} - - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} - - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - csstype@3.2.3: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - depd@2.0.0: {} - - detect-libc@2.1.2: - optional: true - - dotenv-cli@11.0.0: - dependencies: - cross-spawn: 7.0.6 - dotenv: 17.4.2 - dotenv-expand: 12.0.3 - minimist: 1.2.8 - - dotenv-expand@12.0.3: - dependencies: - dotenv: 16.6.1 - - dotenv@16.6.1: {} - - dotenv@17.4.2: {} - - drizzle-orm@0.45.2(postgres@3.4.9): - optionalDependencies: - postgres: 3.4.9 - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - ee-first@1.1.1: {} - - encodeurl@2.0.0: {} - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.2: - dependencies: - es-errors: 1.3.0 - - esbuild@0.28.0: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.0 - '@esbuild/android-arm': 0.28.0 - '@esbuild/android-arm64': 0.28.0 - '@esbuild/android-x64': 0.28.0 - '@esbuild/darwin-arm64': 0.28.0 - '@esbuild/darwin-x64': 0.28.0 - '@esbuild/freebsd-arm64': 0.28.0 - '@esbuild/freebsd-x64': 0.28.0 - '@esbuild/linux-arm': 0.28.0 - '@esbuild/linux-arm64': 0.28.0 - '@esbuild/linux-ia32': 0.28.0 - '@esbuild/linux-loong64': 0.28.0 - '@esbuild/linux-mips64el': 0.28.0 - '@esbuild/linux-ppc64': 0.28.0 - '@esbuild/linux-riscv64': 0.28.0 - '@esbuild/linux-s390x': 0.28.0 - '@esbuild/linux-x64': 0.28.0 - '@esbuild/netbsd-arm64': 0.28.0 - '@esbuild/netbsd-x64': 0.28.0 - '@esbuild/openbsd-arm64': 0.28.0 - '@esbuild/openbsd-x64': 0.28.0 - '@esbuild/openharmony-arm64': 0.28.0 - '@esbuild/sunos-x64': 0.28.0 - '@esbuild/win32-arm64': 0.28.0 - '@esbuild/win32-ia32': 0.28.0 - '@esbuild/win32-x64': 0.28.0 - - escape-html@1.0.3: {} - - etag@1.8.1: {} - - eventsource-parser@3.1.0: {} - - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.1.0 - - express-rate-limit@8.5.2(express@5.2.1): - dependencies: - express: 5.2.1 - ip-address: 10.2.0 - - express@5.2.1: - dependencies: - accepts: 2.0.0 - body-parser: 2.2.2 - content-disposition: 1.1.0 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.3 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.1 - fresh: 2.0.0 - http-errors: 2.0.1 - merge-descriptors: 2.0.0 - mime-types: 3.0.2 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.15.2 - range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 - statuses: 2.0.2 - type-is: 2.1.0 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fast-uri@3.1.2: {} - - finalhandler@2.1.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - forwarded@0.2.0: {} - - fresh@2.0.0: {} - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - generic-pool@3.9.0: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.4 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.2 - - gopd@1.2.0: {} - - has-symbols@1.1.0: {} - - hasown@2.0.4: - dependencies: - function-bind: 1.1.2 - - hono@4.12.23: {} - - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - iceberg-js@0.8.1: {} - - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - - inherits@2.0.4: {} - - ip-address@10.2.0: {} - - ipaddr.js@1.9.1: {} - - is-promise@4.0.0: {} - - isexe@2.0.0: {} - - jose@5.10.0: {} - - jose@6.2.3: {} - - json-schema-traverse@1.0.0: {} - - json-schema-typed@8.0.2: {} - - math-intrinsics@1.1.0: {} - - mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): - dependencies: - '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) - chalk: 5.6.2 - commander: 11.1.0 - redis: 4.7.1 - optionalDependencies: - next: 16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - - media-typer@1.1.0: {} - - merge-descriptors@2.0.0: {} - - mime-db@1.54.0: {} - - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - - minimist@1.2.8: {} - - ms@2.1.3: {} - - nanoid@3.3.12: {} - - negotiator@1.0.0: {} - - next@16.2.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@next/env': 16.2.6 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.34 - caniuse-lite: 1.0.30001797 - postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(react@19.2.4) - optionalDependencies: - '@next/swc-darwin-arm64': 16.2.6 - '@next/swc-darwin-x64': 16.2.6 - '@next/swc-linux-arm64-gnu': 16.2.6 - '@next/swc-linux-arm64-musl': 16.2.6 - '@next/swc-linux-x64-gnu': 16.2.6 - '@next/swc-linux-x64-musl': 16.2.6 - '@next/swc-win32-arm64-msvc': 16.2.6 - '@next/swc-win32-x64-msvc': 16.2.6 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - parseurl@1.3.3: {} - - path-key@3.1.1: {} - - path-to-regexp@8.4.2: {} - - picocolors@1.1.1: {} - - pkce-challenge@5.0.1: {} - - postcss@8.4.31: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postgres@3.4.9: {} - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - qs@6.15.2: - dependencies: - side-channel: 1.1.0 - - range-parser@1.2.1: {} - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 - - react-dom@19.2.4(react@19.2.4): - dependencies: - react: 19.2.4 - scheduler: 0.27.0 - - react@19.2.4: {} - - redis@4.7.1: - dependencies: - '@redis/bloom': 1.2.0(@redis/client@1.6.1) - '@redis/client': 1.6.1 - '@redis/graph': 1.1.1(@redis/client@1.6.1) - '@redis/json': 1.0.7(@redis/client@1.6.1) - '@redis/search': 1.2.0(@redis/client@1.6.1) - '@redis/time-series': 1.1.0(@redis/client@1.6.1) - - require-from-string@2.0.2: {} - - router@2.2.0: - dependencies: - debug: 4.4.3 - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.4.2 - transitivePeerDependencies: - - supports-color - - safer-buffer@2.1.2: {} - - scheduler@0.27.0: {} - - semver@7.8.2: - optional: true - - send@1.2.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@2.2.1: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.1 - transitivePeerDependencies: - - supports-color - - server-only@0.0.1: {} - - setprototypeof@1.2.0: {} - - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.8.2 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - optional: true - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - source-map-js@1.2.1: {} - - statuses@2.0.2: {} - - styled-jsx@5.1.6(react@19.2.4): - dependencies: - client-only: 0.0.1 - react: 19.2.4 - - toidentifier@1.0.1: {} - - tslib@2.8.1: {} - - tsx@4.22.4: - dependencies: - esbuild: 0.28.0 - optionalDependencies: - fsevents: 2.3.3 - - type-is@2.1.0: - dependencies: - content-type: 2.0.0 - media-typer: 1.1.0 - mime-types: 3.0.2 - - typescript@5.9.3: {} - - undici-types@6.21.0: {} - - unpipe@1.0.0: {} - - vary@1.1.2: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wrappy@1.0.2: {} - - yallist@4.0.0: {} - - zod-to-json-schema@3.25.2(zod@3.25.76): - dependencies: - zod: 3.25.76 - - zod@3.25.76: {} diff --git a/scripts/mint-test-token.ts b/scripts/mint-test-token.ts deleted file mode 100644 index 40ea46f..0000000 --- a/scripts/mint-test-token.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createClient } from "@supabase/supabase-js"; - -const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? process.env.SUPABASE_URL; -const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? process.env.SUPABASE_ANON_KEY; -const email = process.env.TEST_EMAIL; -const password = process.env.TEST_PASSWORD; -const mcpUrl = (process.env.MCP_PUBLIC_URL ?? "http://localhost:3000").replace(/\/$/, "") + "/mcp"; - -function fail(msg: string): never { - console.error(`\n✗ ${msg}\n`); - process.exit(1); -} - -async function main(): Promise { - if (!url || !anon) fail("NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY missing (.env.local)."); - if (!email || !password) fail("TEST_EMAIL / TEST_PASSWORD missing (.env.local) — a real account on the target project."); - - const supabase = createClient(url, anon, { auth: { persistSession: false } }); - - const { data, error } = await supabase.auth.signInWithPassword({ email, password }); - if (error || !data.session) fail(`Sign-in failed: ${error?.message ?? "no session"}`); - - const token = data.session.access_token; - const expiresIn = data.session.expires_in; - - console.log("\n✓ Supabase access token (valid ~%ds):\n", expiresIn); - console.log(token); - console.log("\n— List tools via MCP Inspector (CLI):\n"); - console.log( - `npx @modelcontextprotocol/inspector --cli ${mcpUrl} \\\n` + - ` --transport http --method tools/list \\\n` + - ` --header "Authorization: Bearer ${token}"`, - ); - const callCmd = (tool: string, args: string[] = []) => - `npx @modelcontextprotocol/inspector --cli ${mcpUrl} \\\n` + - ` --transport http --method tools/call --tool-name ${tool} ${args.map((a) => `--tool-arg ${a} `).join("")}\\\n` + - ` --header "Authorization: Bearer ${token}"`; - - console.log("\n— whoami (check identity):\n"); - console.log(callCmd("whoami")); - console.log("\n— my World Cup standing:\n"); - console.log(callCmd("get_my_standing", ["competition=wc"])); - console.log("\n— my predictions (friendlies):\n"); - console.log(callCmd("get_my_predictions", ["competition=friendlies", "when=all"])); - console.log(""); - process.exit(0); -} - -void main(); diff --git a/scripts/oauth-e2e.mjs b/scripts/oauth-e2e.mjs deleted file mode 100644 index 3ce77c7..0000000 --- a/scripts/oauth-e2e.mjs +++ /dev/null @@ -1,151 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; -import { writeFileSync } from "node:fs"; - -const BASE = (process.env.NEXT_PUBLIC_SUPABASE_URL ?? "").replace(/\/$/, ""); -const ISSUER = `${BASE}/auth/v1`; -const APIKEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ""; -const TEST_EMAIL = process.env.TEST_EMAIL ?? ""; -const TEST_PASSWORD = process.env.TEST_PASSWORD ?? ""; -const MCP_URL = `${(process.env.MCP_PUBLIC_URL ?? "https://mcp.footics.app").replace(/\/$/, "")}/mcp`; -const REDIRECT_URI = "http://localhost:8976/cb"; -if (!BASE || !APIKEY || !TEST_EMAIL || !TEST_PASSWORD) { - console.error("Missing variables (.env.local): NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, TEST_EMAIL, TEST_PASSWORD"); - process.exit(1); -} - -const b64url = (buf) => Buffer.from(buf).toString("base64url"); -const decodeJwt = (tok) => { - const [h, p] = tok.split("."); - return { header: JSON.parse(Buffer.from(h, "base64url")), claims: JSON.parse(Buffer.from(p, "base64url")) }; -}; -const step = (n, msg) => console.log(`\n[${n}] ${msg}`); - -step(1, "Discovery (RFC 8414)"); -const disco = await (await fetch(`${BASE}/.well-known/oauth-authorization-server/auth/v1`)).json(); -console.log("issuer:", disco.issuer, "| DCR:", !!disco.registration_endpoint, "| scopes:", disco.scopes_supported.join(" ")); - -step(2, "Dynamic client registration"); -const regRes = await fetch(disco.registration_endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - client_name: "Footics MCP e2e probe", - redirect_uris: [REDIRECT_URI], - token_endpoint_auth_method: "none", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - }), -}); -const reg = await regRes.json(); -if (!regRes.ok) throw new Error(`DCR failed ${regRes.status}: ${JSON.stringify(reg)}`); -console.log("client_id:", reg.client_id, "| type:", reg.client_type ?? "?"); - -step(3, "GET /oauth/authorize (PKCE S256, standard scope)"); -const verifier = b64url(randomBytes(48)); -const challenge = b64url(createHash("sha256").update(verifier).digest()); -const state = b64url(randomBytes(12)); -const authorizeUrl = new URL(`${ISSUER}/oauth/authorize`); -authorizeUrl.search = new URLSearchParams({ - client_id: reg.client_id, - redirect_uri: REDIRECT_URI, - response_type: "code", - code_challenge: challenge, - code_challenge_method: "S256", - scope: "openid email profile", - state, -}).toString(); -const authRes = await fetch(authorizeUrl, { redirect: "manual" }); -const location = authRes.headers.get("location"); -console.log("status:", authRes.status, "→", location); -if (!location) throw new Error(`no redirect: ${await authRes.text()}`); -const authorizationId = new URL(location).searchParams.get("authorization_id"); -if (!authorizationId) throw new Error("authorization_id missing from redirect"); -console.log("authorization_id:", authorizationId); - -step(4, "Log in test account (password grant)"); -const loginRes = await fetch(`${ISSUER}/token?grant_type=password`, { - method: "POST", - headers: { "Content-Type": "application/json", apikey: APIKEY }, - body: JSON.stringify({ email: TEST_EMAIL, password: TEST_PASSWORD }), -}); -const session = await loginRes.json(); -if (!loginRes.ok) throw new Error(`login failed: ${JSON.stringify(session)}`); -console.log("session OK (aud:", decodeJwt(session.access_token).claims.aud + ")"); - -step(5, "GET authorization details (as the consent page does)"); -const detRes = await fetch(`${ISSUER}/oauth/authorizations/${authorizationId}`, { - headers: { apikey: APIKEY, Authorization: `Bearer ${session.access_token}` }, -}); -const details = await detRes.json(); -console.log(detRes.status, JSON.stringify({ client: details.client?.name, scope: details.scope, redirect_uri: details.redirect_uri, user: details.user?.email })); - -step(6, "POST consent approve"); -const appRes = await fetch(`${ISSUER}/oauth/authorizations/${authorizationId}/consent`, { - method: "POST", - headers: { "Content-Type": "application/json", apikey: APIKEY, Authorization: `Bearer ${session.access_token}` }, - body: JSON.stringify({ action: "approve" }), -}); -const approved = await appRes.json(); -if (!appRes.ok) throw new Error(`approve failed ${appRes.status}: ${JSON.stringify(approved)}`); -const cbUrl = new URL(approved.redirect_url); -const code = cbUrl.searchParams.get("code"); -console.log("redirect_url → code:", code?.slice(0, 8) + "…", "| state ok:", cbUrl.searchParams.get("state") === state); - -step(7, "POST /oauth/token (authorization_code + PKCE)"); -const tokRes = await fetch(disco.token_endpoint, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: REDIRECT_URI, - client_id: reg.client_id, - code_verifier: verifier, - }), -}); -const tokens = await tokRes.json(); -if (!tokRes.ok) throw new Error(`token exchange failed ${tokRes.status}: ${JSON.stringify(tokens)}`); -writeFileSync("/tmp/sb-oauth-token.txt", tokens.access_token); -writeFileSync("/tmp/sb-oauth-refresh.txt", tokens.refresh_token ?? ""); -const { header, claims } = decodeJwt(tokens.access_token); -console.log("alg:", header.alg, "| expires_in:", tokens.expires_in, "| refresh:", !!tokens.refresh_token); -console.log("CLAIMS:", JSON.stringify(claims, null, 1)); - -step(8, "GET /auth/v1/user with the OAuth token"); -const guRes = await fetch(`${ISSUER}/user`, { headers: { apikey: APIKEY, Authorization: `Bearer ${tokens.access_token}` } }); -const gu = await guRes.json(); -console.log(guRes.status, guRes.ok ? `user: ${gu.email}` : JSON.stringify(gu)); - -step(9, `tools/call whoami on ${MCP_URL} with the OAuth token`); -const mcpRes = await fetch(MCP_URL, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", Authorization: `Bearer ${tokens.access_token}` }, - body: JSON.stringify({ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "whoami", arguments: {} } }), -}); -console.log("MCP:", mcpRes.status, (await mcpRes.text()).slice(0, 250).replace(/\n/g, " ")); - -step(10, "grant_type=refresh_token"); -const refRes = await fetch(disco.token_endpoint, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: reg.client_id }), -}); -const refreshed = await refRes.json(); -console.log(refRes.status, refRes.ok ? `new token OK (expires_in ${refreshed.expires_in})` : JSON.stringify(refreshed)); - -step(11, "authorize with scope=footics:read (custom-scope behavior)"); -const u2 = new URL(`${ISSUER}/oauth/authorize`); -u2.search = new URLSearchParams({ - client_id: reg.client_id, - redirect_uri: REDIRECT_URI, - response_type: "code", - code_challenge: challenge, - code_challenge_method: "S256", - scope: "footics:read", - state, -}).toString(); -const a2 = await fetch(u2, { redirect: "manual" }); -const loc2 = a2.headers.get("location") ?? ""; -console.log("status:", a2.status, "→", loc2.slice(0, 140), loc2.includes("error") ? "(ERROR → PRM must advertise standard scopes)" : ""); - -console.log("\n✓ E2E complete"); diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 803331c..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "react-jsx", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": [ - "./*" - ] - } - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" - ], - "exclude": [ - "node_modules" - ] -} diff --git a/vercel.json b/vercel.json deleted file mode 100644 index ea1974d..0000000 --- a/vercel.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "framework": "nextjs", - "regions": ["fra1"] -}