Compare commits
10 Commits
main
...
748b485c9b
| Author | SHA1 | Date | |
|---|---|---|---|
| 748b485c9b | |||
| 5ca084bd4a | |||
| 5558db6d56 | |||
| 1e925a2b07 | |||
| 080c4238e8 | |||
| ffde884f5b | |||
| 2a58833190 | |||
| c30863ab9b | |||
| 2cca79cf04 | |||
| 033cec6573 |
@@ -20,3 +20,6 @@ database/*.sqlite-journal
|
||||
.phpunit.result.cache
|
||||
docs/*.pdf
|
||||
public/storage
|
||||
|
||||
# Runs on a developer machine, next to Claude Code, never in the image.
|
||||
mcp
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
# Production environment for the Docker stack.
|
||||
#
|
||||
# cp .env.production.example .env.production
|
||||
# docker compose run --rm --no-deps app php artisan key:generate --show
|
||||
# # fill in at least DB_PASSWORD below — docker-compose.yml refuses to
|
||||
# # interpolate at all without it, even for the next command
|
||||
# docker compose --env-file .env.production run --rm --no-deps landing-app php artisan key:generate --show
|
||||
#
|
||||
# Everything the containers need is here; docker-compose.yml sets the database,
|
||||
# cache, session and queue drivers itself so they cannot drift.
|
||||
#
|
||||
# This file only matters for a manual deploy. A platform that injects its own
|
||||
# "Environment Variables" (Dokploy, Coolify, ...) exports them as real process
|
||||
# environment variables instead, which docker-compose.yml's `${VAR}`
|
||||
# interpolation reads directly — no file needed on the server at all.
|
||||
###############################################################################
|
||||
|
||||
APP_NAME="Pireon Admin"
|
||||
@@ -116,3 +123,18 @@ MAIL_USERNAME=
|
||||
MAIL_PASSWORD=
|
||||
MAIL_FROM_ADDRESS="admin@example.org"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
###############################################################################
|
||||
# MCP server (landing-mcp)
|
||||
#
|
||||
# Holds no credentials: every caller signs in with their own. Its domain is
|
||||
# added in Dokploy's "Domains" tab, pointing at port 3000.
|
||||
###############################################################################
|
||||
|
||||
# "true" refuses callers without credentials instead of offering them the
|
||||
# public API — which is already public, so the default is to allow it.
|
||||
LANDING_MCP_REQUIRE_AUTH=false
|
||||
|
||||
# Empty on purpose. Set to SITE_PREVIEW_TOKEN's value and anyone who can sign
|
||||
# in sees every draft through the public tools, whatever their role.
|
||||
LANDING_MCP_PREVIEW_TOKEN=
|
||||
|
||||
21
.github/workflows/tests.yml
vendored
21
.github/workflows/tests.yml
vendored
@@ -79,3 +79,24 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- run: docker compose -f docker-compose.test.yml run --rm test
|
||||
|
||||
# The MCP server derives its tools from openapi.yaml, so a change to the
|
||||
# document can break it without touching a line under mcp/ — two operations
|
||||
# that map to the same tool name, say. Cheap enough to run on every push.
|
||||
mcp:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mcp
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: mcp/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- run: npm test
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,3 +27,4 @@ _ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
/mcp/node_modules
|
||||
|
||||
15
.mcp.json
Normal file
15
.mcp.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"landing": {
|
||||
"command": "node",
|
||||
"args": ["mcp/server.mjs"],
|
||||
"env": {
|
||||
"LANDING_URL": "${LANDING_URL:-http://localhost:8000}",
|
||||
"LANDING_USERNAME": "${LANDING_USERNAME:-}",
|
||||
"LANDING_PASSWORD": "${LANDING_PASSWORD:-}",
|
||||
"LANDING_TOKEN": "${LANDING_TOKEN:-}",
|
||||
"LANDING_PREVIEW_TOKEN": "${LANDING_PREVIEW_TOKEN:-}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,9 +113,9 @@ RUN php artisan package:discover --ansi \
|
||||
|
||||
RUN chown -R www-data:www-data storage bootstrap/cache
|
||||
|
||||
ENV SERVER_NAME=:80
|
||||
ENV SERVER_NAME=:8080
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["entrypoint"]
|
||||
|
||||
|
||||
119
README.md
119
README.md
@@ -1332,6 +1332,99 @@ Va **commiteado** —quien lo necesita debería poder leerlo sin ejecutar nada
|
||||
viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su
|
||||
prosa, y un tipo a secas no la conserva.
|
||||
|
||||
## Servidor MCP
|
||||
|
||||
[`mcp/`](mcp/) es un servidor MCP (Node ≥ 20) que da a Claude —o a cualquier
|
||||
cliente MCP— las dos APIs como herramientas: `public_projects_index`,
|
||||
`admin_pages_update`, `admin_roles_catalogue`… Una por operación documentada,
|
||||
con el nombre de la ruta de Laravel en la que acaba.
|
||||
|
||||
**No hay ninguna escrita a mano.** Lee el `openapi.yaml` que sirve la
|
||||
aplicación a la que apunta y las genera de ahí, así que describe la API con la
|
||||
que está hablando —local, staging o producción— y no la de este checkout. Por lo
|
||||
mismo que los tipos: el documento lo sujetan los tests de contrato, y una
|
||||
segunda descripción de la API escrita a mano no la sujetaría nada. La
|
||||
introducción del documento (las dos APIs, idiomas, campos traducibles) viaja
|
||||
como instrucciones del servidor.
|
||||
|
||||
### Desplegado (HTTP)
|
||||
|
||||
Va en el stack como un contenedor más, `landing-mcp`, que habla con
|
||||
`landing-app` por la red interna. En Dokploy, añádele un dominio en la pestaña
|
||||
«Domains» apuntando al puerto 3000; el endpoint es `https://<dominio>/mcp`
|
||||
(Streamable HTTP, sin estado).
|
||||
|
||||
**No tiene credenciales propias.** Cada petición trae las de quien la hace, y
|
||||
la API aplica los permisos de esa persona, igual que en el panel:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http landing https://mcp.example.org/mcp \
|
||||
--header "Authorization: Basic $(printf 'tu@email.com:contraseña' | base64)"
|
||||
```
|
||||
|
||||
- **`Basic`** con el email y la contraseña de Pireon: el servidor hace login la
|
||||
primera vez, guarda la sesión en memoria (indexada por un hash, nunca en
|
||||
disco ni en logs), refresca el token antes de que caduque y la olvida tras
|
||||
12 horas sin uso. Es lo recomendable, porque los tokens de Pireon caducan.
|
||||
- **`Bearer`** con un token de Pireon: funciona hasta que caduca. Se valida
|
||||
contra `GET /v1/me` y se revalida cada minuto, como hace la propia app.
|
||||
- **Sin cabecera**: solo la API pública, que ya es pública.
|
||||
`LANDING_MCP_REQUIRE_AUTH=true` lo cierra.
|
||||
|
||||
Unas credenciales rechazadas se recuerdan un minuto, sin volver a preguntar a
|
||||
la app. Por eso mismo `POST /v1/auth/token` limita **por cuenta y dirección**
|
||||
(6/min) y no solo por dirección (con un techo de 30/min por dirección): todos
|
||||
los logins del MCP salen del mismo contenedor, y un límite por IP haría que la
|
||||
contraseña mal escrita de uno —o los intentos de un extraño— dejaran fuera a
|
||||
todos los demás.
|
||||
|
||||
El documento se relee cada minuto, así que un endpoint nuevo desplegado en la
|
||||
app aparece como herramienta sin redesplegar el MCP; si la app está caída, se
|
||||
sigue usando el último que cargó.
|
||||
|
||||
`LANDING_MCP_PREVIEW_TOKEN` está vacío a propósito: con el valor de
|
||||
`SITE_PREVIEW_TOKEN`, cualquiera que pueda iniciar sesión ve todos los
|
||||
borradores por las herramientas públicas, sea cual sea su rol. Nunca se manda
|
||||
a quien llega sin credenciales.
|
||||
|
||||
### En local (stdio)
|
||||
|
||||
```bash
|
||||
cd mcp && npm install
|
||||
```
|
||||
|
||||
En este repo ya está registrado en [`.mcp.json`](.mcp.json), que lo lanza por
|
||||
stdio con la configuración del entorno. Aquí sí las credenciales van en
|
||||
variables, porque quien lo lanza es quien inicia sesión:
|
||||
|
||||
```
|
||||
LANDING_URL raíz de la aplicación (por defecto http://localhost:8000)
|
||||
LANDING_USERNAME credenciales de Pireon para la API de administración;
|
||||
LANDING_PASSWORD el token se pide y se refresca solo
|
||||
LANDING_TOKEN o un bearer token fijo en vez de credenciales
|
||||
LANDING_PREVIEW_TOKEN opcional: las herramientas públicas ven borradores
|
||||
LANDING_OPENAPI opcional: ruta o URL de la especificación
|
||||
```
|
||||
|
||||
`node mcp/server.mjs --http` arranca el modo HTTP en local (en `127.0.0.1:3000`
|
||||
salvo `LANDING_MCP_HOST`/`LANDING_MCP_PORT`), para probarlo sin Docker.
|
||||
|
||||
### En los dos modos
|
||||
|
||||
- **Sin credenciales solo aparece la mitad pública.** Ofrecer herramientas que
|
||||
solo pueden fallar le cuesta al modelo una llamada cada una para descubrirlo.
|
||||
- **Fuera a propósito**: `POST /v1/auth/token` (es cosa del cliente), la
|
||||
telemetría (la manda el frontend con su propio secreto) y los dos formularios
|
||||
del buzón — una llamada a `contact` o `apply` mete una fila en el buzón y
|
||||
avisa a gente real.
|
||||
- Las cabeceras no se exponen como parámetros: son secretos, y salen de la
|
||||
configuración, no del modelo.
|
||||
|
||||
`npm test` (en `mcp/`, y en CI) genera las herramientas desde el
|
||||
`openapi.yaml` de este repo —un cambio en el documento que haga chocar dos
|
||||
nombres rompe el build aquí, no la sesión de alguien— y prueba el modo HTTP de
|
||||
punta a punta contra una app simulada.
|
||||
|
||||
## Comandos
|
||||
|
||||
```bash
|
||||
@@ -1364,7 +1457,7 @@ php artisan site:maintenance --up
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
php artisan test # 394 tests, 1128 aserciones
|
||||
php artisan test # 391 tests, 1121 aserciones
|
||||
```
|
||||
|
||||
En cada push y cada pull request, [`.github/workflows/tests.yml`](.github/workflows/tests.yml)
|
||||
@@ -1458,7 +1551,7 @@ párrafo aparte sin relación programática con él.
|
||||
## Estructura
|
||||
|
||||
```
|
||||
docker-compose.yml stack de producción (app, worker, scheduler, postgres)
|
||||
docker-compose.yml stack de producción (landing-app, worker, scheduler, landing-mcp, postgres)
|
||||
docker-compose.test.yml la suite contra Postgres, desechable
|
||||
Dockerfile imagen única para los tres contenedores PHP
|
||||
docker/ Caddyfile, php.ini y entrypoint
|
||||
@@ -1491,6 +1584,7 @@ config/site.php frontend público, preview, caché, rate
|
||||
config/media.php disco, anchos y formatos de imagen
|
||||
config/git.php proveedores de git y etiquetas de issues
|
||||
public/openapi.yaml especificación de ambas APIs
|
||||
mcp/ servidor MCP (stdio y HTTP) generado desde la anterior
|
||||
```
|
||||
|
||||
## Despliegue con Docker
|
||||
@@ -1499,16 +1593,29 @@ Cuatro contenedores y una base de datos:
|
||||
|
||||
```bash
|
||||
cp .env.production.example .env.production
|
||||
docker compose run --rm --no-deps app php artisan key:generate --show # pégalo en APP_KEY
|
||||
# Ahora edita .env.production: al menos DB_PASSWORD tiene que tener un valor
|
||||
# real, porque docker-compose.yml no arranca sin él — ni siquiera para el
|
||||
# comando siguiente, que solo interpola el fichero entero antes de ejecutar
|
||||
# nada. APP_KEY puede quedarse vacío por ahora, se rellena con este paso:
|
||||
|
||||
docker compose --env-file .env.production run --rm --no-deps landing-app php artisan key:generate --show # pégalo en APP_KEY
|
||||
docker compose --env-file .env.production up -d --build
|
||||
|
||||
# el primer administrador, igual que en local
|
||||
docker compose exec app php artisan user:role tu@email.com super_admin
|
||||
docker compose exec landing-app php artisan user:role tu@email.com super_admin
|
||||
```
|
||||
|
||||
Todas las variables de `docker-compose.yml` llegan por interpolación `${VAR}`,
|
||||
no por `env_file:` — así que esto funciona igual con `--env-file`, con
|
||||
variables ya exportadas en el shell, o con el panel de "Environment
|
||||
Variables" de una plataforma como Dokploy o Coolify, que las inyecta como
|
||||
variables de entorno reales del proceso que lanza `docker compose`. Ninguna
|
||||
de las tres formas necesita que exista un fichero `.env.production` en el
|
||||
servidor.
|
||||
|
||||
| Contenedor | Qué hace |
|
||||
|---|---|
|
||||
| `app` | FrankenPHP sirviendo el panel y la API |
|
||||
| `landing-app` | FrankenPHP sirviendo el panel y la API |
|
||||
| `worker` | procesa imágenes y manda los avisos del buzón |
|
||||
| `scheduler` | cierra convocatorias, barre imágenes pendientes, sincroniza repos |
|
||||
| `postgres` | la base de datos |
|
||||
@@ -1587,7 +1694,7 @@ docker compose -f docker-compose.test.yml run --rm test
|
||||
|
||||
Levanta un Postgres en memoria, corre la suite entera contra él y lo tira. Es la
|
||||
comprobación que convierte «debería funcionar» en «funciona», y conviene hacerla
|
||||
antes del primer despliegue. **394 tests en verde contra Postgres 17.**
|
||||
antes del primer despliegue. **391 tests en verde contra Postgres 17.**
|
||||
|
||||
Usa una etapa propia de la imagen: la de producción se construye con `--no-dev`
|
||||
y ahí no está ni el runner de tests.
|
||||
|
||||
@@ -54,6 +54,21 @@ class AppServiceProvider extends ServiceProvider
|
||||
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)
|
||||
->by($request->user()?->id ?: $request->ip()));
|
||||
|
||||
// Backs POST /api/v1/auth/token. Budgeted per account and address
|
||||
// rather than per address alone, because the deployed MCP server
|
||||
// (mcp/) signs everyone in from one container: per IP, one person's
|
||||
// wrong password — or a stranger's guesses — would lock every other
|
||||
// user out of it. The per-address ceiling still bounds how many
|
||||
// accounts a single client can try.
|
||||
RateLimiter::for('auth-token', fn (Request $request) => [
|
||||
Limit::perMinute(6)->by('auth-token:'.sha1(
|
||||
Str::lower((string) ($request->input('username') ?: $request->input('refresh_token')))
|
||||
.'|'.$request->ip()
|
||||
)),
|
||||
|
||||
Limit::perMinute(30)->by('auth-token-ip:'.$request->ip()),
|
||||
]);
|
||||
|
||||
// The public API is a different problem. The public frontend renders
|
||||
// server-side, so every visitor's page view arrives from the same IP:
|
||||
// a per-IP budget sized for one person would throttle the entire
|
||||
|
||||
@@ -86,14 +86,6 @@ class AdminPanelProvider extends PanelProvider
|
||||
])
|
||||
|
||||
// Language switcher, rendered next to the user menu.
|
||||
// First render hook to fire on every page, panel and login alike:
|
||||
// the one place that has to be true regardless of which screen
|
||||
// somebody lands on first.
|
||||
->renderHook(
|
||||
PanelsRenderHook::BODY_START,
|
||||
fn (): string => view('filament.partials.environment-banner')->render(),
|
||||
)
|
||||
|
||||
->renderHook(
|
||||
PanelsRenderHook::USER_MENU_BEFORE,
|
||||
fn (): string => view('filament.partials.language-switcher')->render(),
|
||||
|
||||
@@ -1,36 +1,59 @@
|
||||
###############################################################################
|
||||
# Production stack.
|
||||
#
|
||||
# Four containers and one database. Deliberately no Redis: Postgres holds the
|
||||
# Five containers and one database. Deliberately no Redis: Postgres holds the
|
||||
# cache, the sessions and the queue, which for a site this size is plenty and
|
||||
# is one less thing to run, back up and forget to secure. Swapping it in later
|
||||
# is three environment variables.
|
||||
#
|
||||
# Every application value below comes from `${VAR}` interpolation, not
|
||||
# `env_file:` — so this works the same way whichever way the variables reach
|
||||
# `docker compose`: a real shell environment, a platform's own "Environment
|
||||
# Variables" panel (Dokploy, Coolify, ...), or an explicit `--env-file`:
|
||||
#
|
||||
# docker compose --env-file .env.production up -d --build
|
||||
#
|
||||
# `APP_KEY` is deliberately not required here even though the application
|
||||
# cannot run without it: `docker/entrypoint.sh` checks it instead, with a
|
||||
# message that says how to generate one. Requiring it at this level would also
|
||||
# break generating it in the first place — `docker compose run --rm --no-deps
|
||||
# landing-app php artisan key:generate --show` has to work before `APP_KEY`
|
||||
# exists.
|
||||
#
|
||||
# The web service is named `landing-app`, not `app`: Compose aliases a
|
||||
# service to its own name on every network it joins, `dokploy-network`
|
||||
# included, and that network is shared with whatever else Dokploy runs on the
|
||||
# same host. A service called plain `app` collides with the equally common
|
||||
# `app` another compose project puts on the same network — its containers
|
||||
# start resolving `app` to *this* one, on a port nothing here is listening on.
|
||||
# That is exactly what broke `oauth-iam`'s PHP-FPM upstream the moment both
|
||||
# stacks were up together, and it stops happening the moment the name isn't
|
||||
# generic enough to collide.
|
||||
#
|
||||
# `landing-app` is not published on a host port either: it only joins the
|
||||
# external `dokploy-network` so Dokploy's Traefik can reach it, and TLS is
|
||||
# terminated there before anything reaches this container — same reason
|
||||
# `docker/Caddyfile` trusts `X-Forwarded-*` only from private ranges. The
|
||||
# domain itself is configured through Dokploy's own "Domains" tab, which
|
||||
# injects its own Traefik labels — don't also add them here, Traefik ends up
|
||||
# with two routers for the same Host() and picks one unpredictably.
|
||||
#
|
||||
# A manual deploy without Dokploy needs its own reverse proxy joined to that
|
||||
# network (with its own labels/config), or `landing-app` added back to
|
||||
# `default` with a published port.
|
||||
#
|
||||
# The web container is the only one that migrates, so a deploy runs them once
|
||||
# rather than once per replica.
|
||||
###############################################################################
|
||||
|
||||
name: landing
|
||||
|
||||
x-app: &app
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: pireon-admin:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env.production
|
||||
environment:
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
|
||||
# The maintenance flag and the health badge are read on nearly every
|
||||
# request, so the cache has to be shared by every container — a per-process
|
||||
# one would have the panel and the API disagreeing about whether the site
|
||||
# is down.
|
||||
CACHE_STORE: database
|
||||
SESSION_DRIVER: database
|
||||
QUEUE_CONNECTION: database
|
||||
volumes:
|
||||
# Uploaded images live here, and they are the one thing in this stack that
|
||||
# cannot be rebuilt from the repository.
|
||||
@@ -39,23 +62,95 @@ x-app: &app
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
services:
|
||||
app:
|
||||
<<: *app
|
||||
environment:
|
||||
x-app-env: &app-env
|
||||
APP_NAME: ${APP_NAME:-Pireon Admin}
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
APP_KEY: ${APP_KEY}
|
||||
APP_DEBUG: ${APP_DEBUG:-false}
|
||||
APP_URL: ${APP_URL}
|
||||
APP_LOCALE: ${APP_LOCALE:-es}
|
||||
APP_FALLBACK_LOCALE: ${APP_FALLBACK_LOCALE:-es}
|
||||
|
||||
LOG_CHANNEL: ${LOG_CHANNEL:-stack}
|
||||
LOG_STACK: ${LOG_STACK:-single}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-warning}
|
||||
|
||||
# Fixed by the service names below, not something a deploy should override —
|
||||
# otherwise the panel and the API could disagree about the site's state.
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: ${DB_DATABASE:-pireon}
|
||||
DB_USERNAME: ${DB_USERNAME:-pireon}
|
||||
DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required. Set it wherever this stack's other variables come from.}
|
||||
CACHE_STORE: database
|
||||
SESSION_DRIVER: database
|
||||
QUEUE_CONNECTION: database
|
||||
|
||||
PIREON_SERVER: ${PIREON_SERVER:-https://oauth.pireon.dev}
|
||||
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID}
|
||||
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET}
|
||||
PIREON_REDIRECT_URI: ${PIREON_REDIRECT_URI}
|
||||
PIREON_SCOPES: ${PIREON_SCOPES:-account:read}
|
||||
PIREON_PASSWORD_GRANT: ${PIREON_PASSWORD_GRANT:-true}
|
||||
PIREON_INTROSPECTION_TTL: ${PIREON_INTROSPECTION_TTL:-60}
|
||||
PIREON_DEFAULT_ROLE: ${PIREON_DEFAULT_ROLE:-viewer}
|
||||
PIREON_REQUIRE_ACTIVE: ${PIREON_REQUIRE_ACTIVE:-true}
|
||||
PIREON_REQUIRE_APPROVED: ${PIREON_REQUIRE_APPROVED:-true}
|
||||
|
||||
SITE_URL: ${SITE_URL}
|
||||
SITE_PREFIX_DEFAULT_LOCALE: ${SITE_PREFIX_DEFAULT_LOCALE:-false}
|
||||
SITE_CACHE_SECONDS: ${SITE_CACHE_SECONDS:-60}
|
||||
SITE_PREVIEW_TOKEN: ${SITE_PREVIEW_TOKEN}
|
||||
SITE_RATE_LIMIT: ${SITE_RATE_LIMIT:-600}
|
||||
SITE_TRUSTED_IPS: ${SITE_TRUSTED_IPS:-}
|
||||
SITE_INGEST_TOKEN: ${SITE_INGEST_TOKEN}
|
||||
SITE_TELEMETRY_RATE_LIMIT: ${SITE_TELEMETRY_RATE_LIMIT:-60}
|
||||
SITE_TELEMETRY_MAX_BATCH: ${SITE_TELEMETRY_MAX_BATCH:-50}
|
||||
SITE_ACCEPT_SUBMISSIONS: ${SITE_ACCEPT_SUBMISSIONS:-true}
|
||||
SITE_HONEYPOT_FIELD: ${SITE_HONEYPOT_FIELD:-website}
|
||||
SITE_SUBMISSION_RATE_LIMIT: ${SITE_SUBMISSION_RATE_LIMIT:-3}
|
||||
SITE_SUBMISSION_HOURLY_LIMIT: ${SITE_SUBMISSION_HOURLY_LIMIT:-60}
|
||||
SITE_SUBMISSION_DEDUPE_MINUTES: ${SITE_SUBMISSION_DEDUPE_MINUTES:-1440}
|
||||
SITE_SUBMISSION_MAIL: ${SITE_SUBMISSION_MAIL:-false}
|
||||
|
||||
MEDIA_DISK: ${MEDIA_DISK:-public}
|
||||
MEDIA_MAX_PIXELS: ${MEDIA_MAX_PIXELS:-50000000}
|
||||
|
||||
GIT_PROVIDER: ${GIT_PROVIDER:-gitea}
|
||||
GITEA_URL: ${GITEA_URL:-}
|
||||
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
||||
GITEA_ORG: ${GITEA_ORG:-}
|
||||
GIT_SYNC_CRON: ${GIT_SYNC_CRON:-hourly}
|
||||
|
||||
MAIL_MAILER: ${MAIL_MAILER:-log}
|
||||
MAIL_HOST: ${MAIL_HOST:-}
|
||||
MAIL_PORT: ${MAIL_PORT:-587}
|
||||
MAIL_USERNAME: ${MAIL_USERNAME:-}
|
||||
MAIL_PASSWORD: ${MAIL_PASSWORD:-}
|
||||
MAIL_FROM_ADDRESS: ${MAIL_FROM_ADDRESS:-admin@example.org}
|
||||
MAIL_FROM_NAME: ${MAIL_FROM_NAME:-Pireon Admin}
|
||||
|
||||
services:
|
||||
landing-app:
|
||||
<<: *app
|
||||
environment:
|
||||
<<: *app-env
|
||||
# Only here: migrations run once per deploy, not once per container.
|
||||
RUN_MIGRATIONS: "true"
|
||||
ports:
|
||||
- "${APP_PORT:-8000}:80"
|
||||
expose:
|
||||
- "8080"
|
||||
networks:
|
||||
- dokploy-network
|
||||
- default
|
||||
|
||||
# No Traefik labels here on purpose: the domain is configured through
|
||||
# Dokploy's own "Domains" tab for this app, which injects its own labels
|
||||
# at deploy time. Hand-writing another set on top of those, for the same
|
||||
# Host(), gave Traefik two routers to choose between — it only broke once
|
||||
# both existed together, which is exactly what happened.
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost/up"]
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/up"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 40s
|
||||
@@ -67,6 +162,7 @@ services:
|
||||
worker:
|
||||
<<: *app
|
||||
command: ["php", "artisan", "queue:work", "--tries=3", "--max-time=3600", "--sleep=3"]
|
||||
environment: *app-env
|
||||
|
||||
# Waits for the web container, not just the database: it is the one that
|
||||
# migrates, and a worker that starts against an empty schema spends its
|
||||
@@ -74,7 +170,7 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
app:
|
||||
landing-app:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "php", "artisan", "queue:monitor", "default"]
|
||||
@@ -89,6 +185,7 @@ services:
|
||||
scheduler:
|
||||
<<: *app
|
||||
command: ["php", "artisan", "schedule:work"]
|
||||
environment: *app-env
|
||||
|
||||
# The FrankenPHP base image ships a healthcheck that probes Caddy's admin
|
||||
# API, and this container does not serve HTTP — inherited, it could never
|
||||
@@ -109,17 +206,56 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
app:
|
||||
landing-app:
|
||||
condition: service_healthy
|
||||
stop_grace_period: 30s
|
||||
|
||||
# The MCP server (mcp/), so Claude and other MCP clients can use the API
|
||||
# from anywhere, not only from a checkout of this repository. It reaches the
|
||||
# application over the stack's own network rather than the public domain:
|
||||
# one hop, no TLS, and it keeps working while the domain is misconfigured.
|
||||
#
|
||||
# Holds no credentials of its own. Every request brings its caller's Pireon
|
||||
# credentials, and the API applies that person's permissions — see the
|
||||
# README's "Servidor MCP" section for how to connect.
|
||||
#
|
||||
# Like `landing-app`, published only through `dokploy-network`: add its
|
||||
# domain in Dokploy's "Domains" tab, pointing at port 3000, and the endpoint
|
||||
# is https://<that domain>/mcp.
|
||||
landing-mcp:
|
||||
build:
|
||||
context: ./mcp
|
||||
image: landing-mcp:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LANDING_URL: http://landing-app:8080
|
||||
LANDING_MCP_REQUIRE_AUTH: ${LANDING_MCP_REQUIRE_AUTH:-false}
|
||||
# Deliberately not SITE_PREVIEW_TOKEN by default: with it, anyone who can
|
||||
# sign in sees every draft through the public tools, whatever their role
|
||||
# would let them see in the panel. Set it to that value knowingly.
|
||||
LANDING_PREVIEW_TOKEN: ${LANDING_MCP_PREVIEW_TOKEN:-}
|
||||
expose:
|
||||
- "3000"
|
||||
networks:
|
||||
- dokploy-network
|
||||
- default
|
||||
depends_on:
|
||||
landing-app:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/up').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_DATABASE:-pireon}
|
||||
POSTGRES_USER: ${DB_USERNAME:-pireon}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env.production}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required. Set it wherever this stack's other variables come from.}
|
||||
|
||||
# Content is written by a handful of people and read constantly; the
|
||||
# default collation rules are what the JSON slug lookups sort under.
|
||||
@@ -141,3 +277,7 @@ services:
|
||||
volumes:
|
||||
pgdata:
|
||||
storage:
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
|
||||
@@ -30,11 +30,6 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'environment' => [
|
||||
'production' => 'Producció — això és real',
|
||||
'other' => 'Entorn de :name',
|
||||
],
|
||||
|
||||
'concurrency' => [
|
||||
'title' => 'No s\'ha desat',
|
||||
'body' => 'Algú altre ha canviat això mentre ho editaves. Recarrega la pàgina per veure la versió actual: si ho deses ara, perdries aquest canvi.',
|
||||
|
||||
@@ -30,11 +30,6 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'environment' => [
|
||||
'production' => 'Production — this is real',
|
||||
'other' => ':name environment',
|
||||
],
|
||||
|
||||
'concurrency' => [
|
||||
'title' => 'Not saved',
|
||||
'body' => 'Somebody else changed this while you were editing it. Reload the page to see the current version — saving now would overwrite that change.',
|
||||
|
||||
@@ -30,11 +30,6 @@ return [
|
||||
],
|
||||
],
|
||||
|
||||
'environment' => [
|
||||
'production' => 'Producción — esto es real',
|
||||
'other' => 'Entorno de :name',
|
||||
],
|
||||
|
||||
'concurrency' => [
|
||||
'title' => 'No se ha guardado',
|
||||
'body' => 'Alguien más ha cambiado esto mientras lo editabas. Recarga la página para ver la versión actual: si guardas ahora, perderías ese cambio.',
|
||||
|
||||
4
mcp/.dockerignore
Normal file
4
mcp/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
test
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
26
mcp/Dockerfile
Normal file
26
mcp/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
###############################################################################
|
||||
# The MCP server, over HTTP. Its own image rather than a fourth command in the
|
||||
# PHP one: it shares no code with the application, only the API between them,
|
||||
# and Node is a runtime that image has no other reason to carry.
|
||||
###############################################################################
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
LANDING_MCP_HOST=0.0.0.0 \
|
||||
LANDING_MCP_PORT=3000
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY server.mjs ./
|
||||
COPY src ./src
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.mjs", "--http"]
|
||||
1222
mcp/package-lock.json
generated
Normal file
1222
mcp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
mcp/package.json
Normal file
20
mcp/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "landing-mcp",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "MCP server exposing the landing REST API as tools, generated from its OpenAPI document.",
|
||||
"bin": {
|
||||
"landing-mcp": "server.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.30.1",
|
||||
"yaml": "^2.9.1"
|
||||
}
|
||||
}
|
||||
94
mcp/server.mjs
Normal file
94
mcp/server.mjs
Normal file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* MCP server for the landing API.
|
||||
*
|
||||
* Reads the OpenAPI document the running application serves and offers one
|
||||
* tool per documented operation, so it always describes the API it is talking
|
||||
* to — local, staging or production — rather than the one in this checkout.
|
||||
*
|
||||
* node server.mjs stdio, for a client that launches it locally
|
||||
* node server.mjs --http Streamable HTTP on /mcp, for deploying
|
||||
*
|
||||
* Configuration, all through the environment:
|
||||
*
|
||||
* LANDING_URL Application root. Default http://localhost:8000
|
||||
* LANDING_OPENAPI Path or URL of the spec, if not LANDING_URL/openapi.yaml.
|
||||
* LANDING_PREVIEW_TOKEN Lets the public tools see unpublished content. Over
|
||||
* HTTP, only for signed-in callers.
|
||||
*
|
||||
* stdio only — the one person running it is the one it signs in as:
|
||||
*
|
||||
* LANDING_USERNAME Pireon credentials for the admin API (/v1). The
|
||||
* LANDING_PASSWORD token is obtained and refreshed automatically.
|
||||
* LANDING_TOKEN Or a fixed bearer token instead.
|
||||
*
|
||||
* HTTP only — every request brings its own credentials (see src/http.mjs):
|
||||
*
|
||||
* LANDING_MCP_PORT Default 3000.
|
||||
* LANDING_MCP_HOST Default 127.0.0.1; the image sets 0.0.0.0.
|
||||
* LANDING_MCP_REQUIRE_AUTH "true" refuses anonymous callers instead of
|
||||
* offering them the public API.
|
||||
*
|
||||
* stdout is the stdio protocol channel: anything for a human goes to stderr.
|
||||
*/
|
||||
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { Catalogue } from './src/catalogue.mjs';
|
||||
import { LandingClient } from './src/client.mjs';
|
||||
import { startHttpServer } from './src/http.mjs';
|
||||
import { createMcpServer } from './src/mcp.mjs';
|
||||
|
||||
const env = (name) => process.env[name]?.trim() || undefined;
|
||||
|
||||
const baseUrl = (env('LANDING_URL') ?? 'http://localhost:8000').replace(/\/+$/, '');
|
||||
const http = process.argv.includes('--http');
|
||||
|
||||
const catalogue = new Catalogue({
|
||||
source: env('LANDING_OPENAPI') ?? `${baseUrl}/openapi.yaml`,
|
||||
baseUrl,
|
||||
// A deployment outlives many deploys of the application; a stdio process
|
||||
// lives for one editor session and describes its tools once anyway.
|
||||
ttlMs: http ? 60_000 : Infinity,
|
||||
});
|
||||
|
||||
if (http) {
|
||||
// Credentials in the environment would sign every anonymous caller in as
|
||||
// whoever they belong to.
|
||||
for (const name of ['LANDING_USERNAME', 'LANDING_PASSWORD', 'LANDING_TOKEN']) {
|
||||
if (env(name)) console.error(`landing-mcp: ${name} is ignored over HTTP; each request brings its own credentials`);
|
||||
}
|
||||
|
||||
// Tried now only to report a misconfiguration early. The application may
|
||||
// well still be starting, so a failure here is not fatal: the catalogue
|
||||
// retries on the first request.
|
||||
catalogue.get().catch((error) => console.error(`landing-mcp: OpenAPI document not available yet: ${error.message}`));
|
||||
|
||||
startHttpServer({
|
||||
catalogue,
|
||||
port: Number(env('LANDING_MCP_PORT') ?? 3000),
|
||||
host: env('LANDING_MCP_HOST') ?? '127.0.0.1',
|
||||
previewToken: env('LANDING_PREVIEW_TOKEN'),
|
||||
requireAuth: env('LANDING_MCP_REQUIRE_AUTH') === 'true',
|
||||
});
|
||||
} else {
|
||||
let current;
|
||||
|
||||
try {
|
||||
current = await catalogue.get();
|
||||
} catch (error) {
|
||||
console.error(`landing-mcp: could not load the OpenAPI document from ${catalogue.source}: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new LandingClient({
|
||||
apiBase: current.apiBase,
|
||||
token: env('LANDING_TOKEN'),
|
||||
username: env('LANDING_USERNAME'),
|
||||
password: env('LANDING_PASSWORD'),
|
||||
previewToken: env('LANDING_PREVIEW_TOKEN'),
|
||||
});
|
||||
|
||||
await createMcpServer({ spec: current.spec, tools: current.tools, client }).connect(new StdioServerTransport());
|
||||
|
||||
console.error(`landing-mcp: stdio against ${current.apiBase}${client.canAuthenticate() ? '' : ' (public API only: no credentials)'}`);
|
||||
}
|
||||
78
mcp/src/catalogue.mjs
Normal file
78
mcp/src/catalogue.mjs
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The OpenAPI document and the tools derived from it.
|
||||
*
|
||||
* Loaded from the application the server talks to, and — for a long-running
|
||||
* deployment — reloaded now and then, so that deploying a new endpoint to the
|
||||
* application makes it a tool here without also redeploying this.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { parse } from 'yaml';
|
||||
import { buildTools } from './tools.mjs';
|
||||
|
||||
export class Catalogue {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.source Path or URL of openapi.yaml.
|
||||
* @param {string} options.baseUrl Application root the document's server URL is relative to.
|
||||
* @param {number} [options.ttlMs] How long a loaded document is trusted. Infinity: load once.
|
||||
*/
|
||||
constructor({ source, baseUrl, ttlMs = Infinity }) {
|
||||
this.source = source;
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
this.ttlMs = ttlMs;
|
||||
this.current = null;
|
||||
this.loadedAt = 0;
|
||||
this.pending = null;
|
||||
}
|
||||
|
||||
/** @returns {Promise<{spec: object, tools: import('./tools.mjs').Tool[], apiBase: string}>} */
|
||||
async get() {
|
||||
if (this.current && Date.now() - this.loadedAt < this.ttlMs) return this.current;
|
||||
|
||||
this.pending ??= this.load().finally(() => { this.pending = null; });
|
||||
|
||||
try {
|
||||
return await this.pending;
|
||||
} catch (error) {
|
||||
// An application that is redeploying, or briefly down, should not take
|
||||
// the tools with it: the last document that loaded is still the best
|
||||
// description available. Only the very first load has nothing to fall
|
||||
// back on.
|
||||
if (this.current) {
|
||||
console.error(`landing-mcp: keeping the previous OpenAPI document: ${error.message}`);
|
||||
this.loadedAt = Date.now();
|
||||
|
||||
return this.current;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
const spec = parse(await this.read());
|
||||
|
||||
// The document's own server URL ("/api"), relative to the application root.
|
||||
const serverUrl = spec.servers?.[0]?.url ?? '/api';
|
||||
|
||||
this.current = {
|
||||
spec,
|
||||
tools: buildTools(spec),
|
||||
apiBase: /^https?:\/\//.test(serverUrl) ? serverUrl : `${this.baseUrl}${serverUrl}`,
|
||||
};
|
||||
this.loadedAt = Date.now();
|
||||
|
||||
return this.current;
|
||||
}
|
||||
|
||||
async read() {
|
||||
if (!/^https?:\/\//.test(this.source)) return readFile(this.source, 'utf8');
|
||||
|
||||
const response = await fetch(this.source, { signal: AbortSignal.timeout(15_000) });
|
||||
|
||||
if (!response.ok) throw new Error(`GET ${this.source} answered ${response.status}`);
|
||||
|
||||
return response.text();
|
||||
}
|
||||
}
|
||||
198
mcp/src/client.mjs
Normal file
198
mcp/src/client.mjs
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Calls the API on the model's behalf, and keeps it signed in.
|
||||
*
|
||||
* Tokens come from the Pireon identity server through POST /api/v1/auth/token
|
||||
* and expire, so a fixed bearer token in the MCP config would die mid-session.
|
||||
* With credentials instead, this client logs in on the first authenticated
|
||||
* call, refreshes before expiry, and retries once on a 401 — a token revoked or
|
||||
* expired upstream costs one extra round trip, not a broken session.
|
||||
*/
|
||||
|
||||
// Refresh this long before the stated expiry, so a token never expires between
|
||||
// the check and the request.
|
||||
const EXPIRY_MARGIN_MS = 60_000;
|
||||
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
export class LandingClient {
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.apiBase e.g. https://admin.example.com/api
|
||||
* @param {string} [options.token] A fixed bearer token.
|
||||
* @param {string} [options.username]
|
||||
* @param {string} [options.password]
|
||||
* @param {string} [options.previewToken] Sent on public calls to see drafts.
|
||||
* @param {typeof fetch} [options.fetch]
|
||||
*/
|
||||
constructor({ apiBase, token, username, password, previewToken, fetch = globalThis.fetch }) {
|
||||
this.apiBase = apiBase.replace(/\/+$/, '');
|
||||
this.fixedToken = token || null;
|
||||
this.credentials = username && password ? { username, password } : null;
|
||||
this.previewToken = previewToken || null;
|
||||
this.fetch = fetch;
|
||||
|
||||
/** @type {{access: string, refresh: string|null, expiresAt: number|null}|null} */
|
||||
this.session = null;
|
||||
this.pending = null;
|
||||
}
|
||||
|
||||
canAuthenticate() {
|
||||
return Boolean(this.fixedToken || this.credentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proves the credentials or token work, by spending them on GET /v1/me.
|
||||
* Throws for credentials the application refused; resolves the response
|
||||
* otherwise, for the caller to judge its status.
|
||||
*/
|
||||
async verify() {
|
||||
return this.call({ method: 'GET', path: '/v1/me', params: [], hasBody: false, requiresAuth: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./tools.mjs').Operation} operation
|
||||
* @param {Record<string, unknown>} args
|
||||
* @returns {Promise<{status: number, body: unknown}>}
|
||||
*/
|
||||
async call(operation, args = {}) {
|
||||
const request = this.buildRequest(operation, args);
|
||||
|
||||
if (!operation.requiresAuth) {
|
||||
if (this.previewToken) request.headers['X-Preview-Token'] = this.previewToken;
|
||||
|
||||
return this.send(request);
|
||||
}
|
||||
|
||||
request.headers.Authorization = `Bearer ${await this.accessToken()}`;
|
||||
|
||||
let response = await this.send(request);
|
||||
|
||||
// A fixed token has nothing to renew itself with, so its 401 stands.
|
||||
if (response.status === 401 && !this.fixedToken) {
|
||||
this.session = { ...this.session, access: null };
|
||||
request.headers.Authorization = `Bearer ${await this.accessToken()}`;
|
||||
response = await this.send(request);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
buildRequest(operation, args) {
|
||||
let path = operation.path;
|
||||
const query = new URLSearchParams();
|
||||
|
||||
for (const { name, in: location } of operation.params) {
|
||||
const value = args[name];
|
||||
|
||||
if (location === 'path') {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
throw new Error(`Missing path parameter "${name}"`);
|
||||
}
|
||||
|
||||
path = path.replace(`{${name}}`, encodeURIComponent(String(value)));
|
||||
} else if (value !== undefined && value !== null) {
|
||||
// Laravel's boolean() and the `boolean` rule both accept 1/0; only the
|
||||
// former accepts "true".
|
||||
query.set(name, typeof value === 'boolean' ? (value ? '1' : '0') : String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const headers = { Accept: 'application/json' };
|
||||
let body;
|
||||
|
||||
if (operation.hasBody && args.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify(args.body);
|
||||
}
|
||||
|
||||
const qs = query.toString();
|
||||
|
||||
return {
|
||||
url: `${this.apiBase}${path}${qs ? `?${qs}` : ''}`,
|
||||
method: operation.method,
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
async send({ url, method, headers, body }) {
|
||||
const response = await this.fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let parsed = text;
|
||||
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
// Not JSON (an HTML error page from a proxy, say): hand it over as text.
|
||||
}
|
||||
|
||||
return { status: response.status, body: parsed };
|
||||
}
|
||||
|
||||
async accessToken() {
|
||||
if (this.fixedToken) return this.fixedToken;
|
||||
|
||||
if (!this.credentials) {
|
||||
throw new Error('This endpoint needs a token: set LANDING_TOKEN, or LANDING_USERNAME and LANDING_PASSWORD.');
|
||||
}
|
||||
|
||||
const session = this.session;
|
||||
const fresh = session?.access
|
||||
&& (session.expiresAt === null || session.expiresAt - Date.now() > EXPIRY_MARGIN_MS);
|
||||
|
||||
if (fresh) return session.access;
|
||||
|
||||
// Parallel tool calls would otherwise each log in, and the token endpoint
|
||||
// allows six requests a minute.
|
||||
this.pending ??= this.renew().finally(() => { this.pending = null; });
|
||||
|
||||
return (await this.pending).access;
|
||||
}
|
||||
|
||||
async renew() {
|
||||
if (this.session?.refresh) {
|
||||
try {
|
||||
return await this.exchange({ grant_type: 'refresh_token', refresh_token: this.session.refresh });
|
||||
} catch {
|
||||
// Refresh tokens expire and get revoked too; the password still works.
|
||||
}
|
||||
}
|
||||
|
||||
return this.exchange({ grant_type: 'password', ...this.credentials });
|
||||
}
|
||||
|
||||
async exchange(grant) {
|
||||
const { status, body } = await this.send({
|
||||
url: `${this.apiBase}/v1/auth/token`,
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(grant),
|
||||
});
|
||||
|
||||
if (status !== 200 || !body?.access_token) {
|
||||
const reason = body?.message ?? (typeof body === 'string' ? body.slice(0, 200) : `HTTP ${status}`);
|
||||
const error = new Error(`Could not obtain a token (${grant.grant_type} grant): ${reason}`);
|
||||
|
||||
// 401/403/422 are the application's answer about these credentials;
|
||||
// anything else says nothing about them. The HTTP transport needs to
|
||||
// tell the two apart.
|
||||
error.status = status;
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.session = {
|
||||
access: body.access_token,
|
||||
refresh: body.refresh_token ?? null,
|
||||
expiresAt: body.expires_at ? Date.parse(body.expires_at) : null,
|
||||
};
|
||||
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
288
mcp/src/http.mjs
Normal file
288
mcp/src/http.mjs
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* The MCP server over Streamable HTTP, for deploying next to the application.
|
||||
*
|
||||
* Stateless: every POST builds a fresh MCP server around the caller's client,
|
||||
* so nothing about one request survives into the next except the sign-in
|
||||
* below, and any number of replicas can sit behind the same domain.
|
||||
*
|
||||
* Nobody signs in to this server as such. Each request carries its caller's
|
||||
* own credentials, and the API applies that person's permissions exactly as the
|
||||
* panel would:
|
||||
*
|
||||
* Authorization: Basic base64(email:password) Pireon credentials. Signed
|
||||
* in once, then kept fresh.
|
||||
* Authorization: Bearer <token> A Pireon access token.
|
||||
* (none) Public API only, unless
|
||||
* LANDING_MCP_REQUIRE_AUTH.
|
||||
*
|
||||
* Credentials never reach a log, and are held only in memory, keyed by a hash,
|
||||
* for as long as they are in use.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { LandingClient } from './client.mjs';
|
||||
import { createMcpServer } from './mcp.mjs';
|
||||
|
||||
const MAX_BODY_BYTES = 1_000_000;
|
||||
|
||||
// A refused credential is refused again without asking the application, for
|
||||
// this long. A client retrying in a loop with a wrong password would otherwise
|
||||
// spend the account's sign-in budget for its owner too.
|
||||
const REJECTION_TTL_MS = 60_000;
|
||||
|
||||
// A bearer token is re-checked this often, matching the application's own
|
||||
// introspection cache: past that, a token revoked upstream stops working here
|
||||
// at the same moment it stops working there.
|
||||
const BEARER_TTL_MS = 60_000;
|
||||
|
||||
// A signed-in session nobody has used for this long is forgotten, password
|
||||
// included.
|
||||
const IDLE_MS = 12 * 60 * 60 * 1000;
|
||||
const MAX_SESSIONS = 500;
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {import('./catalogue.mjs').Catalogue} options.catalogue
|
||||
* @param {number} options.port
|
||||
* @param {string} options.host
|
||||
* @param {string} [options.previewToken] Only ever sent for signed-in callers.
|
||||
* @param {boolean} [options.requireAuth]
|
||||
*/
|
||||
export function startHttpServer({ catalogue, port, host, previewToken, requireAuth = false }) {
|
||||
const sessions = new Sessions({ previewToken, requireAuth });
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
handle(req, res, { catalogue, sessions }).catch((error) => {
|
||||
console.error(`landing-mcp: ${error.stack ?? error.message}`);
|
||||
|
||||
if (!res.headersSent) sendError(res, 500, 'Internal error');
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.error(`landing-mcp: listening on http://${host}:${port}/mcp${requireAuth ? '' : ' (anonymous callers get the public API)'}`);
|
||||
});
|
||||
|
||||
// PID 1 in a container: without this, `docker stop` waits out its timeout
|
||||
// and then kills whatever request was in flight.
|
||||
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||
process.once(signal, () => server.close(() => process.exit(0)));
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async function handle(req, res, { catalogue, sessions }) {
|
||||
const { pathname } = new URL(req.url, 'http://localhost');
|
||||
|
||||
// Liveness only, like the application's /up: it must not fail because the
|
||||
// application is redeploying, or both would restart each other.
|
||||
if (pathname === '/up') return send(res, 200, { status: 'ok' });
|
||||
|
||||
if (pathname !== '/mcp') return sendError(res, 404, 'Not found');
|
||||
|
||||
// Stateless: there is no session to open a stream on or to delete.
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', 'POST');
|
||||
|
||||
return sendError(res, 405, 'This server is stateless and only accepts POST');
|
||||
}
|
||||
|
||||
let current;
|
||||
|
||||
try {
|
||||
current = await catalogue.get();
|
||||
} catch (error) {
|
||||
return sendError(res, 503, `The API description is not available: ${error.message}`);
|
||||
}
|
||||
|
||||
let client;
|
||||
|
||||
try {
|
||||
client = await sessions.authenticate(req.headers.authorization, current.apiBase);
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError)) throw error;
|
||||
|
||||
if (error.status === 401) res.setHeader('WWW-Authenticate', 'Basic realm="landing-mcp", charset="UTF-8"');
|
||||
|
||||
return sendError(res, error.status, error.message);
|
||||
}
|
||||
|
||||
let body;
|
||||
|
||||
try {
|
||||
body = await readJson(req);
|
||||
} catch (error) {
|
||||
return sendError(res, error.status ?? 400, error.message);
|
||||
}
|
||||
|
||||
const mcp = createMcpServer({ spec: current.spec, tools: current.tools, client });
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
||||
|
||||
res.on('close', () => {
|
||||
transport.close();
|
||||
mcp.close();
|
||||
});
|
||||
|
||||
await mcp.connect(transport);
|
||||
await transport.handleRequest(req, res, body);
|
||||
}
|
||||
|
||||
class Sessions {
|
||||
constructor({ previewToken, requireAuth }) {
|
||||
this.previewToken = previewToken;
|
||||
this.requireAuth = requireAuth;
|
||||
|
||||
/** @type {Map<string, {client: Promise<LandingClient>, lastUsed: number, expiresAt: number}>} */
|
||||
this.entries = new Map();
|
||||
/** @type {Map<string, number>} */
|
||||
this.rejected = new Map();
|
||||
this.anonymous = null;
|
||||
}
|
||||
|
||||
/** @returns {Promise<LandingClient>} */
|
||||
async authenticate(header, apiBase) {
|
||||
if (!header) {
|
||||
if (this.requireAuth) throw new HttpError(401, 'Authentication required: send Basic credentials or a Bearer token');
|
||||
|
||||
// No preview secret: that would hand drafts to anyone who can reach this.
|
||||
this.anonymous ??= new LandingClient({ apiBase });
|
||||
|
||||
return this.anonymous;
|
||||
}
|
||||
|
||||
const credentials = parseAuthorization(header);
|
||||
|
||||
if (!credentials) throw new HttpError(401, 'Unsupported Authorization header: use Basic or Bearer');
|
||||
|
||||
const now = Date.now();
|
||||
const key = createHash('sha256').update(header).digest('hex');
|
||||
|
||||
this.sweep(now);
|
||||
|
||||
if ((this.rejected.get(key) ?? 0) > now) throw new HttpError(401, 'Credentials refused by the application');
|
||||
|
||||
const entry = this.entries.get(key);
|
||||
|
||||
if (entry && entry.expiresAt > now) {
|
||||
entry.lastUsed = now;
|
||||
|
||||
return entry.client;
|
||||
}
|
||||
|
||||
// The promise, not the client, so concurrent first requests with the same
|
||||
// credentials sign in once between them.
|
||||
const client = this.open(credentials, apiBase);
|
||||
|
||||
this.entries.set(key, {
|
||||
client,
|
||||
lastUsed: now,
|
||||
expiresAt: credentials.token ? now + BEARER_TTL_MS : Infinity,
|
||||
});
|
||||
|
||||
try {
|
||||
return await client;
|
||||
} catch (error) {
|
||||
this.entries.delete(key);
|
||||
|
||||
if (error instanceof HttpError && error.status === 401) this.rejected.set(key, Date.now() + REJECTION_TTL_MS);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async open(credentials, apiBase) {
|
||||
const client = new LandingClient({ apiBase, ...credentials, previewToken: this.previewToken });
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await client.verify();
|
||||
} catch (error) {
|
||||
if ([401, 403, 422].includes(error.status)) throw new HttpError(401, 'Credentials refused by the application');
|
||||
if (error.status === 429) throw new HttpError(429, 'Too many sign-in attempts for this account; wait a minute');
|
||||
|
||||
throw new HttpError(502, `Could not reach the application: ${error.message}`);
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new HttpError(401, 'Token refused by the application');
|
||||
}
|
||||
|
||||
if (response.status !== 200) throw new HttpError(502, `The application answered ${response.status} to GET /v1/me`);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
sweep(now) {
|
||||
for (const [key, until] of this.rejected) {
|
||||
if (until <= now) this.rejected.delete(key);
|
||||
}
|
||||
|
||||
for (const [key, entry] of this.entries) {
|
||||
if (now - entry.lastUsed > IDLE_MS || entry.expiresAt <= now) this.entries.delete(key);
|
||||
}
|
||||
|
||||
// Insertion order is sign-in order: the oldest goes first.
|
||||
while (this.entries.size >= MAX_SESSIONS) this.entries.delete(this.entries.keys().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {{username: string, password: string}|{token: string}|null} */
|
||||
export function parseAuthorization(header) {
|
||||
const match = /^(\w+)\s+(\S+)\s*$/.exec(header);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const [, scheme, value] = match;
|
||||
|
||||
if (scheme.toLowerCase() === 'bearer') return { token: value };
|
||||
|
||||
if (scheme.toLowerCase() === 'basic') {
|
||||
const decoded = Buffer.from(value, 'base64').toString('utf8');
|
||||
const colon = decoded.indexOf(':');
|
||||
|
||||
if (colon < 1 || colon === decoded.length - 1) return null;
|
||||
|
||||
return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readJson(req) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
|
||||
if (size > MAX_BODY_BYTES) throw new HttpError(413, 'Request body too large');
|
||||
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||
} catch {
|
||||
throw new HttpError(400, 'Request body is not valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
function send(res, status, payload) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// Shaped as a JSON-RPC error, which is what an MCP client knows how to show.
|
||||
function sendError(res, status, message) {
|
||||
send(res, status, { jsonrpc: '2.0', error: { code: -32000, message }, id: null });
|
||||
}
|
||||
52
mcp/src/mcp.mjs
Normal file
52
mcp/src/mcp.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* The MCP server itself, independent of the transport it is reached through.
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} options.spec
|
||||
* @param {import('./tools.mjs').Tool[]} options.tools
|
||||
* @param {import('./client.mjs').LandingClient} options.client
|
||||
*/
|
||||
export function createMcpServer({ spec, tools, client }) {
|
||||
// Offering tools that can only fail would cost the model a round trip each
|
||||
// to learn that; without credentials the admin half is simply not there.
|
||||
const offered = tools.filter((tool) => !tool.operation.requiresAuth || client.canAuthenticate());
|
||||
const byName = new Map(offered.map((tool) => [tool.name, tool]));
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'landing', version: spec.info?.version ?? '1.0.0' },
|
||||
{
|
||||
capabilities: { tools: {} },
|
||||
// The document's introduction already explains the two APIs, auth and
|
||||
// how translatable fields are read and written.
|
||||
instructions: spec.info?.description,
|
||||
},
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: offered.map(({ operation, ...tool }) => tool),
|
||||
}));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const tool = byName.get(request.params.name);
|
||||
|
||||
if (!tool) {
|
||||
return { isError: true, content: [{ type: 'text', text: `Unknown tool ${request.params.name}` }] };
|
||||
}
|
||||
|
||||
try {
|
||||
const { status, body } = await client.call(tool.operation, request.params.arguments ?? {});
|
||||
const payload = body === null || body === '' ? '' : `\n${typeof body === 'string' ? body : JSON.stringify(body)}`;
|
||||
|
||||
return { isError: status >= 400, content: [{ type: 'text', text: `HTTP ${status}${payload}` }] };
|
||||
} catch (error) {
|
||||
return { isError: true, content: [{ type: 'text', text: error.message }] };
|
||||
}
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
303
mcp/src/tools.mjs
Normal file
303
mcp/src/tools.mjs
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Turns the API's OpenAPI document into MCP tools, one per operation.
|
||||
*
|
||||
* The tools are derived rather than written by hand for the same reason the
|
||||
* TypeScript types are: `openapi.yaml` is held to the truth by the contract
|
||||
* tests, and nothing would hold a second, hand-written description of the API
|
||||
* to anything. A new endpoint documented there is a new tool here with no
|
||||
* change to this file.
|
||||
*/
|
||||
|
||||
// Endpoints that exist for someone other than an operator. Telemetry is the
|
||||
// frontend reporting back with its own secret; the submissions are a visitor's
|
||||
// forms, and a call to one puts a row in the inbox and notifies real people.
|
||||
const EXCLUDED_TAGS = new Set(['Telemetry', 'Submissions']);
|
||||
|
||||
// Obtaining a token is the client's job, not the model's.
|
||||
const EXCLUDED_OPERATIONS = new Set(['POST /v1/auth/token']);
|
||||
|
||||
const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
|
||||
|
||||
// OpenAPI keywords that either mean nothing to JSON Schema or only describe
|
||||
// responses. `nullable` is translated rather than dropped.
|
||||
const DROPPED_KEYWORDS = new Set([
|
||||
'nullable', 'example', 'readOnly', 'writeOnly', 'discriminator', 'xml', 'externalDocs',
|
||||
]);
|
||||
|
||||
/**
|
||||
* @typedef {object} Operation
|
||||
* @property {string} method Upper-case HTTP method.
|
||||
* @property {string} path Path as documented, e.g. /v1/pages/{page}.
|
||||
* @property {{name: string, in: string}[]} params
|
||||
* @property {boolean} hasBody
|
||||
* @property {boolean} requiresAuth
|
||||
*
|
||||
* @typedef {object} Tool
|
||||
* @property {string} name
|
||||
* @property {string} description
|
||||
* @property {object} inputSchema
|
||||
* @property {object} annotations
|
||||
* @property {Operation} operation Kept server-side; never sent to the client.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {object} spec The parsed OpenAPI document.
|
||||
* @returns {Tool[]}
|
||||
*/
|
||||
export function buildTools(spec) {
|
||||
const paths = Object.keys(spec.paths ?? {});
|
||||
const tools = [];
|
||||
|
||||
for (const path of paths) {
|
||||
const item = spec.paths[path];
|
||||
|
||||
for (const method of METHODS) {
|
||||
const op = item[method];
|
||||
|
||||
if (!op) continue;
|
||||
if (EXCLUDED_OPERATIONS.has(`${method.toUpperCase()} ${path}`)) continue;
|
||||
if ((op.tags ?? []).some((tag) => EXCLUDED_TAGS.has(tag))) continue;
|
||||
|
||||
tools.push(buildTool(spec, paths, path, method, item, op));
|
||||
}
|
||||
}
|
||||
|
||||
// A clash would silently shadow one endpoint behind another, so it is a
|
||||
// startup failure rather than something to discover by calling the wrong one.
|
||||
const seen = new Map();
|
||||
|
||||
for (const tool of tools) {
|
||||
const { method, path } = tool.operation;
|
||||
|
||||
if (seen.has(tool.name)) {
|
||||
throw new Error(`Tool name "${tool.name}" is derived from both ${seen.get(tool.name)} and ${method} ${path}`);
|
||||
}
|
||||
|
||||
seen.set(tool.name, `${method} ${path}`);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
function buildTool(spec, paths, path, method, item, op) {
|
||||
const params = mergeParameters(spec, item.parameters, op.parameters)
|
||||
// Headers are credentials here (preview and ingest secrets), which come
|
||||
// from the server's configuration, never from the model.
|
||||
.filter((param) => param.in === 'path' || param.in === 'query');
|
||||
|
||||
const properties = {};
|
||||
const required = [];
|
||||
|
||||
for (const param of params) {
|
||||
const schema = toJsonSchema(spec, param.schema ?? { type: 'string' });
|
||||
|
||||
properties[param.name] = param.description
|
||||
? { ...schema, description: param.description.trim() }
|
||||
: schema;
|
||||
|
||||
if (param.required || param.in === 'path') required.push(param.name);
|
||||
}
|
||||
|
||||
const bodySchema = op.requestBody?.content?.['application/json']?.schema;
|
||||
|
||||
if (bodySchema) {
|
||||
if ('body' in properties) {
|
||||
throw new Error(`${method.toUpperCase()} ${path} has a parameter named "body", which the request body needs`);
|
||||
}
|
||||
|
||||
const body = toJsonSchema(spec, bodySchema);
|
||||
|
||||
// Create and update share one write schema, so its `required` describes
|
||||
// creation. On a partial update it would force the model to resend fields
|
||||
// it has no reason to touch.
|
||||
if (method === 'patch') delete body.required;
|
||||
|
||||
properties.body = { ...body, description: 'JSON request body.' };
|
||||
|
||||
if (op.requestBody.required) required.push('body');
|
||||
}
|
||||
|
||||
const security = op.security ?? spec.security ?? [];
|
||||
|
||||
return {
|
||||
name: toolName(paths, path, method),
|
||||
description: describe(path, method, op, security.length > 0),
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties,
|
||||
...(required.length ? { required } : {}),
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: {
|
||||
title: op.summary,
|
||||
readOnlyHint: method === 'get',
|
||||
destructiveHint: method === 'delete',
|
||||
idempotentHint: method === 'get' || method === 'put' || method === 'delete',
|
||||
},
|
||||
operation: {
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
params: params.map(({ name, in: location }) => ({ name, in: location })),
|
||||
hasBody: Boolean(bodySchema),
|
||||
requiresAuth: security.length > 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Named after the Laravel route the operation lands on, so a tool reads the
|
||||
* same as `routes/api.php`: /v1/pages/{page} under PATCH is admin_pages_update,
|
||||
* /public/v1/projects/{slug} under GET is public_projects_show.
|
||||
*/
|
||||
export function toolName(paths, path, method) {
|
||||
const segments = path.split('/').filter(Boolean).filter((s) => s !== 'v1');
|
||||
const scope = segments[0] === 'public' ? 'public' : 'admin';
|
||||
|
||||
if (segments[0] === 'public') segments.shift();
|
||||
|
||||
const isParam = (segment) => segment.startsWith('{');
|
||||
const endsInParam = isParam(segments.at(-1));
|
||||
const base = segments.filter((s) => !isParam(s)).map((s) => s.replaceAll('-', '_'));
|
||||
|
||||
let action = null;
|
||||
|
||||
if (endsInParam) {
|
||||
action = { get: 'show', put: 'update', patch: 'update', delete: 'destroy' }[method];
|
||||
} else if (method === 'get') {
|
||||
// A collection has an item route next to it; /site or /radar does not,
|
||||
// and is named after itself alone.
|
||||
const hasItemRoute = paths.some((p) => p.startsWith(`${path}/{`));
|
||||
action = hasItemRoute ? 'index' : null;
|
||||
} else if (method === 'post') {
|
||||
// /pages/{page}/restore is an action named by its last segment already.
|
||||
action = segments.length > 1 && isParam(segments.at(-2)) ? null : 'store';
|
||||
} else {
|
||||
action = method;
|
||||
}
|
||||
|
||||
return [scope, ...base, ...(action ? [action] : [])].join('_');
|
||||
}
|
||||
|
||||
function describe(path, method, op, requiresAuth) {
|
||||
const parts = [op.summary, op.description].filter(Boolean).map((s) => s.trim());
|
||||
|
||||
parts.push(`${method.toUpperCase()} /api${path} · ${requiresAuth ? 'authenticated' : 'public, no token'}`);
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Path-level parameters apply to every operation under the path; an operation
|
||||
* may redeclare one, and its own declaration wins.
|
||||
*/
|
||||
function mergeParameters(spec, pathLevel = [], operationLevel = []) {
|
||||
const merged = new Map();
|
||||
|
||||
for (const raw of [...pathLevel, ...operationLevel]) {
|
||||
const param = resolve(spec, raw);
|
||||
merged.set(`${param.in}:${param.name}`, param);
|
||||
}
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function resolve(spec, node) {
|
||||
let current = node;
|
||||
const visited = new Set();
|
||||
|
||||
while (current && typeof current.$ref === 'string') {
|
||||
if (visited.has(current.$ref)) throw new Error(`Circular $ref ${current.$ref}`);
|
||||
|
||||
visited.add(current.$ref);
|
||||
current = lookup(spec, current.$ref);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function lookup(spec, ref) {
|
||||
if (!ref.startsWith('#/')) throw new Error(`Only local $refs are supported, got ${ref}`);
|
||||
|
||||
const target = ref
|
||||
.slice(2)
|
||||
.split('/')
|
||||
.map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~'))
|
||||
.reduce((node, key) => node?.[key], spec);
|
||||
|
||||
if (target === undefined) throw new Error(`Unresolvable $ref ${ref}`);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAPI 3.0 schema → self-contained JSON Schema: references inlined (MCP
|
||||
* clients receive each tool's schema on its own, with no `components` to point
|
||||
* into) and `nullable` spelled the way JSON Schema spells it.
|
||||
*/
|
||||
export function toJsonSchema(spec, schema, stack = []) {
|
||||
if (schema === null || typeof schema !== 'object') return schema;
|
||||
|
||||
if (typeof schema.$ref === 'string') {
|
||||
// A recursive schema cannot be inlined forever; past the first repeat the
|
||||
// model gets "anything" and the server's validation has the last word.
|
||||
if (stack.includes(schema.$ref)) return {};
|
||||
|
||||
const { $ref, nullable: isNullable, ...siblings } = schema;
|
||||
|
||||
return nullable(isNullable, {
|
||||
...toJsonSchema(spec, lookup(spec, $ref), [...stack, $ref]),
|
||||
...toJsonSchema(spec, siblings, stack),
|
||||
});
|
||||
}
|
||||
|
||||
// `allOf: [{$ref}]` next to a description is how this document annotates a
|
||||
// shared schema in place. Flattened, the model reads one schema, not a
|
||||
// composition of one.
|
||||
if (Array.isArray(schema.allOf) && schema.allOf.length === 1) {
|
||||
const { allOf, nullable: isNullable, ...siblings } = schema;
|
||||
|
||||
return nullable(isNullable, {
|
||||
...toJsonSchema(spec, allOf[0], stack),
|
||||
...toJsonSchema(spec, siblings, stack),
|
||||
});
|
||||
}
|
||||
|
||||
const out = {};
|
||||
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
if (DROPPED_KEYWORDS.has(key)) continue;
|
||||
|
||||
if (key === 'properties') {
|
||||
out.properties = Object.fromEntries(
|
||||
Object.entries(value)
|
||||
// Response-only fields have no business in something the model writes.
|
||||
.filter(([, property]) => !resolve(spec, property)?.readOnly)
|
||||
.map(([name, property]) => [name, toJsonSchema(spec, property, stack)]),
|
||||
);
|
||||
} else if (['allOf', 'oneOf', 'anyOf'].includes(key)) {
|
||||
out[key] = value.map((member) => toJsonSchema(spec, member, stack));
|
||||
} else if (['items', 'additionalProperties', 'not'].includes(key) && typeof value === 'object') {
|
||||
out[key] = toJsonSchema(spec, value, stack);
|
||||
} else {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return nullable(schema.nullable, out);
|
||||
}
|
||||
|
||||
function nullable(isNullable, schema) {
|
||||
if (!isNullable) return schema;
|
||||
|
||||
if (typeof schema.type === 'string') {
|
||||
const out = { ...schema, type: [schema.type, 'null'] };
|
||||
|
||||
if (Array.isArray(out.enum) && !out.enum.includes(null)) out.enum = [...out.enum, null];
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const { description, ...rest } = schema;
|
||||
|
||||
return { ...(description ? { description } : {}), anyOf: [rest, { type: 'null' }] };
|
||||
}
|
||||
151
mcp/test/client.test.mjs
Normal file
151
mcp/test/client.test.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { LandingClient } from '../src/client.mjs';
|
||||
|
||||
const showPage = {
|
||||
method: 'GET',
|
||||
path: '/v1/pages/{page}',
|
||||
params: [{ name: 'page', in: 'path' }, { name: 'lang', in: 'query' }],
|
||||
hasBody: false,
|
||||
requiresAuth: true,
|
||||
};
|
||||
|
||||
const publicProjects = {
|
||||
method: 'GET',
|
||||
path: '/public/v1/projects',
|
||||
params: [{ name: 'featured', in: 'query' }],
|
||||
hasBody: false,
|
||||
requiresAuth: false,
|
||||
};
|
||||
|
||||
/** A fake fetch that answers from a list of handlers and records every call. */
|
||||
function fakeFetch(handler) {
|
||||
const calls = [];
|
||||
|
||||
const fetch = async (url, init) => {
|
||||
calls.push({ url, ...init });
|
||||
const { status = 200, body = {} } = handler(url, init, calls.length);
|
||||
|
||||
return new Response(status === 204 ? null : JSON.stringify(body), { status });
|
||||
};
|
||||
|
||||
return { fetch, calls };
|
||||
}
|
||||
|
||||
const tokenResponse = (access, expiresInMs = 3_600_000) => ({
|
||||
access_token: access,
|
||||
refresh_token: `refresh-${access}`,
|
||||
expires_at: new Date(Date.now() + expiresInMs).toISOString(),
|
||||
});
|
||||
|
||||
test('builds the URL from path and query parameters', async () => {
|
||||
const { fetch, calls } = fakeFetch(() => ({ body: { data: [] } }));
|
||||
const client = new LandingClient({ apiBase: 'http://app.test/api/', fetch });
|
||||
|
||||
await client.call(publicProjects, { featured: true });
|
||||
|
||||
assert.equal(calls[0].url, 'http://app.test/api/public/v1/projects?featured=1');
|
||||
assert.equal(calls[0].headers.Authorization, undefined);
|
||||
});
|
||||
|
||||
test('sends the preview secret on public calls only', async () => {
|
||||
const { fetch, calls } = fakeFetch((url) => (
|
||||
url.endsWith('/auth/token') ? { body: tokenResponse('a') } : { body: {} }
|
||||
));
|
||||
const client = new LandingClient({
|
||||
apiBase: 'http://app.test/api', username: 'u', password: 'p', previewToken: 'secret', fetch,
|
||||
});
|
||||
|
||||
await client.call(publicProjects, {});
|
||||
await client.call(showPage, { page: 1 });
|
||||
|
||||
assert.equal(calls[0].headers['X-Preview-Token'], 'secret');
|
||||
assert.equal(calls.at(-1).headers['X-Preview-Token'], undefined);
|
||||
});
|
||||
|
||||
test('logs in once and reuses the token', async () => {
|
||||
const { fetch, calls } = fakeFetch((url) => (
|
||||
url.endsWith('/auth/token') ? { body: tokenResponse('a') } : { body: { data: {} } }
|
||||
));
|
||||
const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch });
|
||||
|
||||
await Promise.all([client.call(showPage, { page: 1 }), client.call(showPage, { page: 2 })]);
|
||||
await client.call(showPage, { page: 3 });
|
||||
|
||||
assert.equal(calls.filter((c) => c.url.endsWith('/auth/token')).length, 1);
|
||||
assert.equal(calls.at(-1).url, 'http://app.test/api/v1/pages/3');
|
||||
assert.equal(calls.at(-1).headers.Authorization, 'Bearer a');
|
||||
});
|
||||
|
||||
test('refreshes a token about to expire', async () => {
|
||||
let issued = 0;
|
||||
const { fetch, calls } = fakeFetch((url) => (
|
||||
url.endsWith('/auth/token') ? { body: tokenResponse(`t${++issued}`, 10_000) } : { body: {} }
|
||||
));
|
||||
const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch });
|
||||
|
||||
await client.call(showPage, { page: 1 });
|
||||
await client.call(showPage, { page: 1 });
|
||||
|
||||
const grants = calls.filter((c) => c.url.endsWith('/auth/token')).map((c) => JSON.parse(c.body).grant_type);
|
||||
|
||||
assert.deepEqual(grants, ['password', 'refresh_token']);
|
||||
assert.equal(calls.at(-1).headers.Authorization, 'Bearer t2');
|
||||
});
|
||||
|
||||
test('retries once with a new token after a 401', async () => {
|
||||
let issued = 0;
|
||||
const { fetch, calls } = fakeFetch((url, init) => {
|
||||
if (url.endsWith('/auth/token')) return { body: tokenResponse(`t${++issued}`) };
|
||||
|
||||
return init.headers.Authorization === 'Bearer t1' ? { status: 401, body: { message: 'Unauthenticated.' } } : { body: { ok: true } };
|
||||
});
|
||||
const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch });
|
||||
|
||||
const response = await client.call(showPage, { page: 1 });
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(calls.at(-1).headers.Authorization, 'Bearer t2');
|
||||
});
|
||||
|
||||
test('falls back to the password when the refresh token is refused', async () => {
|
||||
let issued = 0;
|
||||
const { fetch, calls } = fakeFetch((url, init) => {
|
||||
if (!url.endsWith('/auth/token')) return { body: {} };
|
||||
|
||||
return JSON.parse(init.body).grant_type === 'refresh_token'
|
||||
? { status: 422, body: { message: 'invalid' } }
|
||||
: { body: tokenResponse(`t${++issued}`, 10_000) };
|
||||
});
|
||||
const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch });
|
||||
|
||||
await client.call(showPage, { page: 1 });
|
||||
await client.call(showPage, { page: 1 });
|
||||
|
||||
const grants = calls.filter((c) => c.url.endsWith('/auth/token')).map((c) => JSON.parse(c.body).grant_type);
|
||||
|
||||
assert.deepEqual(grants, ['password', 'refresh_token', 'password']);
|
||||
});
|
||||
|
||||
test('a fixed token is used as is and never renewed', async () => {
|
||||
const { fetch, calls } = fakeFetch(() => ({ status: 401, body: {} }));
|
||||
const client = new LandingClient({ apiBase: 'http://app.test/api', token: 'fixed', fetch });
|
||||
|
||||
const response = await client.call(showPage, { page: 1 });
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].headers.Authorization, 'Bearer fixed');
|
||||
});
|
||||
|
||||
test('refuses an authenticated call without credentials', async () => {
|
||||
const client = new LandingClient({ apiBase: 'http://app.test/api', fetch: async () => assert.fail('no request') });
|
||||
|
||||
await assert.rejects(client.call(showPage, { page: 1 }), /LANDING_TOKEN/);
|
||||
});
|
||||
|
||||
test('refuses a call missing a path parameter', async () => {
|
||||
const client = new LandingClient({ apiBase: 'http://app.test/api', token: 't', fetch: async () => assert.fail('no request') });
|
||||
|
||||
await assert.rejects(client.call(showPage, {}), /"page"/);
|
||||
});
|
||||
155
mcp/test/http.test.mjs
Normal file
155
mcp/test/http.test.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { after, before, test } from 'node:test';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { Catalogue } from '../src/catalogue.mjs';
|
||||
import { parseAuthorization, startHttpServer } from '../src/http.mjs';
|
||||
|
||||
const spec = await readFile(new URL('../../public/openapi.yaml', import.meta.url), 'utf8');
|
||||
|
||||
/**
|
||||
* Stands in for the application: the spec, the token endpoint, /v1/me and one
|
||||
* public endpoint that reports whether it was sent the preview secret.
|
||||
*/
|
||||
const app = { logins: 0, requests: [] };
|
||||
|
||||
const appServer = createServer(async (req, res) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
|
||||
|
||||
app.requests.push({ url: req.url, headers: req.headers });
|
||||
|
||||
const json = (status, payload) => {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
if (req.url === '/openapi.yaml') return res.end(spec);
|
||||
|
||||
if (req.url === '/api/v1/auth/token') {
|
||||
app.logins++;
|
||||
|
||||
return body.username === 'ada@example.com' && body.password === 'secret'
|
||||
? json(200, { access_token: 'ada-token', refresh_token: 'r', expires_at: new Date(Date.now() + 3_600_000).toISOString() })
|
||||
: json(422, { message: 'These credentials do not match our records.' });
|
||||
}
|
||||
|
||||
if (req.url.startsWith('/api/v1/')) {
|
||||
return ['Bearer ada-token', 'Bearer valid-token'].includes(req.headers.authorization)
|
||||
? json(200, { data: { email: 'ada@example.com' } })
|
||||
: json(401, { message: 'Unauthenticated.' });
|
||||
}
|
||||
|
||||
if (req.url.startsWith('/api/public/v1/site')) {
|
||||
return json(200, { data: { preview: req.headers['x-preview-token'] === 'drafts' } });
|
||||
}
|
||||
|
||||
json(404, { message: 'Not found' });
|
||||
});
|
||||
|
||||
let mcpUrl;
|
||||
let mcpServer;
|
||||
|
||||
before(async () => {
|
||||
appServer.listen(0, '127.0.0.1');
|
||||
await once(appServer, 'listening');
|
||||
|
||||
const appUrl = `http://127.0.0.1:${appServer.address().port}`;
|
||||
|
||||
mcpServer = startHttpServer({
|
||||
catalogue: new Catalogue({ source: `${appUrl}/openapi.yaml`, baseUrl: appUrl }),
|
||||
port: 0,
|
||||
host: '127.0.0.1',
|
||||
previewToken: 'drafts',
|
||||
});
|
||||
await once(mcpServer, 'listening');
|
||||
|
||||
mcpUrl = new URL(`http://127.0.0.1:${mcpServer.address().port}/mcp`);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
mcpServer.close();
|
||||
appServer.close();
|
||||
});
|
||||
|
||||
async function connect(authorization) {
|
||||
const client = new Client({ name: 'test', version: '0' });
|
||||
const headers = authorization ? { Authorization: authorization } : {};
|
||||
|
||||
await client.connect(new StreamableHTTPClientTransport(mcpUrl, { requestInit: { headers } }));
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
const basic = (user, password) => `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
|
||||
|
||||
test('an anonymous caller gets the public API, without the preview secret', async () => {
|
||||
const client = await connect();
|
||||
const { tools } = await client.listTools();
|
||||
|
||||
assert.ok(tools.length > 0);
|
||||
assert.ok(tools.every((tool) => tool.name.startsWith('public_')));
|
||||
|
||||
const result = await client.callTool({ name: 'public_site', arguments: {} });
|
||||
|
||||
assert.match(result.content[0].text, /"preview":false/);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test('Basic credentials get both APIs, and sign in once across requests', async () => {
|
||||
const before = app.logins;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const client = await connect(basic('ada@example.com', 'secret'));
|
||||
const { tools } = await client.listTools();
|
||||
|
||||
assert.ok(tools.some((tool) => tool.name === 'admin_pages_update'));
|
||||
|
||||
const me = await client.callTool({ name: 'admin_me', arguments: {} });
|
||||
assert.equal(me.isError, false);
|
||||
|
||||
const site = await client.callTool({ name: 'public_site', arguments: {} });
|
||||
assert.match(site.content[0].text, /"preview":true/);
|
||||
|
||||
await client.close();
|
||||
}
|
||||
|
||||
assert.equal(app.logins - before, 1);
|
||||
});
|
||||
|
||||
test('wrong credentials are a 401, remembered rather than retried', async () => {
|
||||
const before = app.logins;
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await assert.rejects(connect(basic('ada@example.com', 'wrong')), /401|Credentials refused/);
|
||||
}
|
||||
|
||||
assert.equal(app.logins - before, 1);
|
||||
});
|
||||
|
||||
test('a bearer token is checked against the application', async () => {
|
||||
const good = await connect('Bearer valid-token');
|
||||
assert.ok((await good.listTools()).tools.some((tool) => tool.name === 'admin_me'));
|
||||
await good.close();
|
||||
|
||||
await assert.rejects(connect('Bearer revoked-token'), /401|refused/);
|
||||
});
|
||||
|
||||
test('only POST /mcp is served, and /up answers without the application', async () => {
|
||||
const base = `http://127.0.0.1:${mcpServer.address().port}`;
|
||||
|
||||
assert.equal((await fetch(`${base}/up`)).status, 200);
|
||||
assert.equal((await fetch(`${base}/mcp`)).status, 405);
|
||||
assert.equal((await fetch(`${base}/elsewhere`)).status, 404);
|
||||
});
|
||||
|
||||
test('parses Authorization headers', () => {
|
||||
assert.deepEqual(parseAuthorization(basic('a@b.c', 'p:with:colons')), { username: 'a@b.c', password: 'p:with:colons' });
|
||||
assert.deepEqual(parseAuthorization('bearer abc'), { token: 'abc' });
|
||||
assert.equal(parseAuthorization(basic('', 'p')), null);
|
||||
assert.equal(parseAuthorization('Digest abc'), null);
|
||||
});
|
||||
81
mcp/test/tools.test.mjs
Normal file
81
mcp/test/tools.test.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { test } from 'node:test';
|
||||
import { parse } from 'yaml';
|
||||
import { buildTools } from '../src/tools.mjs';
|
||||
|
||||
// The document in this checkout: the one the application serves.
|
||||
const spec = parse(await readFile(new URL('../../public/openapi.yaml', import.meta.url), 'utf8'));
|
||||
const tools = buildTools(spec);
|
||||
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
|
||||
test('names follow the Laravel routes', () => {
|
||||
const expected = {
|
||||
public_site: 'GET /public/v1/site',
|
||||
public_good_first_issues: 'GET /public/v1/good-first-issues',
|
||||
public_projects_index: 'GET /public/v1/projects',
|
||||
public_projects_show: 'GET /public/v1/projects/{slug}',
|
||||
public_clients: 'GET /public/v1/clients',
|
||||
admin_me: 'GET /v1/me',
|
||||
admin_pages_index: 'GET /v1/pages',
|
||||
admin_pages_store: 'POST /v1/pages',
|
||||
admin_pages_update: 'PATCH /v1/pages/{page}',
|
||||
admin_pages_destroy: 'DELETE /v1/pages/{page}',
|
||||
admin_pages_restore: 'POST /v1/pages/{page}/restore',
|
||||
admin_roles_catalogue: 'GET /v1/roles/catalogue',
|
||||
};
|
||||
|
||||
for (const [name, route] of Object.entries(expected)) {
|
||||
const tool = byName.get(name);
|
||||
assert.ok(tool, `missing ${name}`);
|
||||
assert.equal(`${tool.operation.method} ${tool.operation.path}`, route);
|
||||
}
|
||||
});
|
||||
|
||||
test('names are valid MCP tool names', () => {
|
||||
for (const { name } of tools) assert.match(name, /^[a-z0-9_]{1,64}$/);
|
||||
});
|
||||
|
||||
test('leaves out what is not for an operator', () => {
|
||||
const routes = tools.map(({ operation }) => `${operation.method} ${operation.path}`);
|
||||
|
||||
assert.ok(!routes.includes('POST /v1/auth/token'));
|
||||
assert.ok(!routes.some((route) => route.includes('/telemetry/')));
|
||||
assert.ok(!routes.includes('POST /public/v1/contact'));
|
||||
assert.ok(!routes.includes('POST /public/v1/calls/{slug}/apply'));
|
||||
});
|
||||
|
||||
test('every other documented operation is a tool', () => {
|
||||
const documented = Object.values(spec.paths)
|
||||
.flatMap((item) => ['get', 'post', 'put', 'patch', 'delete'].filter((m) => item[m])).length;
|
||||
|
||||
// auth/token, two telemetry endpoints, contact and apply.
|
||||
assert.equal(tools.length, documented - 5);
|
||||
});
|
||||
|
||||
test('schemas are self-contained JSON Schema', () => {
|
||||
for (const tool of tools) {
|
||||
const json = JSON.stringify(tool.inputSchema);
|
||||
|
||||
assert.ok(!json.includes('"$ref"'), `${tool.name} still has a $ref`);
|
||||
assert.ok(!json.includes('"nullable"'), `${tool.name} still has nullable`);
|
||||
}
|
||||
});
|
||||
|
||||
test('path parameters are required, headers are not offered', () => {
|
||||
const show = byName.get('public_projects_show');
|
||||
|
||||
assert.deepEqual(show.inputSchema.required, ['slug']);
|
||||
assert.ok(!('X-Preview-Token' in show.inputSchema.properties));
|
||||
});
|
||||
|
||||
test('a create requires its fields, an update does not', () => {
|
||||
assert.deepEqual(byName.get('admin_pages_store').inputSchema.properties.body.required, ['title', 'slug']);
|
||||
assert.equal(byName.get('admin_pages_update').inputSchema.properties.body.required, undefined);
|
||||
});
|
||||
|
||||
test('only the admin API is marked as needing a token', () => {
|
||||
for (const tool of tools) {
|
||||
assert.equal(tool.operation.requiresAuth, tool.name.startsWith('admin_'), tool.name);
|
||||
}
|
||||
});
|
||||
@@ -909,8 +909,9 @@ paths:
|
||||
description: |
|
||||
Proxies the identity server's `password` and `refresh_token` grants so
|
||||
first-party clients need not embed the OAuth server's URL or secret.
|
||||
Rate limited to 6 requests per minute. Browser clients should use
|
||||
Authorization Code + PKCE instead.
|
||||
Rate limited to 6 requests per minute per account and address, and 30
|
||||
per address overall. Browser clients should use Authorization Code +
|
||||
PKCE instead.
|
||||
security: []
|
||||
requestBody:
|
||||
required: true
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{{-- Rendered at the very top of every page in the panel, on every install.
|
||||
|
||||
The failure this exists for runs in one direction only: mistaking a real
|
||||
deployment for a throwaway copy, not the other way round. Nobody edits a
|
||||
local clone carefully because they think it is production — the mistake
|
||||
that costs something is the reverse. So `local` stays silent, and
|
||||
anything else says so, loudly in the one case that matters. --}}
|
||||
|
||||
@unless (app()->environment('local'))
|
||||
<div
|
||||
role="status"
|
||||
@class([
|
||||
'w-full py-1 text-center text-xs font-semibold tracking-wide text-white uppercase',
|
||||
'bg-danger-600' => app()->environment('production'),
|
||||
'bg-warning-600' => ! app()->environment('production'),
|
||||
])
|
||||
>
|
||||
{{ app()->environment('production')
|
||||
? __('panel.environment.production')
|
||||
: __('panel.environment.other', ['name' => app()->environment()]) }}
|
||||
</div>
|
||||
@endunless
|
||||
@@ -152,7 +152,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
// Convenience endpoint for first-party clients: exchanges credentials for
|
||||
// a token upstream so they do not need to know the identity server's URL.
|
||||
Route::post('auth/token', [TokenController::class, 'store'])
|
||||
->middleware('throttle:6,1')
|
||||
->middleware('throttle:auth-token')
|
||||
->name('auth.token');
|
||||
|
||||
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
|
||||
|
||||
@@ -103,6 +103,38 @@ class TokenEndpointTest extends TestCase
|
||||
->assertJsonValidationErrors('grant_type');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function one_account_exhausting_its_budget_does_not_lock_out_another_on_the_same_address(): void
|
||||
{
|
||||
// Everyone the MCP server signs in arrives from its one container.
|
||||
// Registered first so it wins over fakePireon()'s token stub, which
|
||||
// would otherwise accept every password.
|
||||
Http::fake(["{$this->pireonServer}/oauth/token" => fn ($request) => $request['username'] === 'ada@example.com'
|
||||
? Http::response(['access_token' => 'fake-access-token', 'token_type' => 'Bearer', 'expires_in' => 3600])
|
||||
: Http::response(['error' => 'invalid_grant'], 400)]);
|
||||
$this->fakePireon();
|
||||
|
||||
foreach (range(1, 6) as $attempt) {
|
||||
$this->postJson('/api/v1/auth/token', [
|
||||
'grant_type' => 'password',
|
||||
'username' => 'mallory@example.com',
|
||||
'password' => "guess-{$attempt}",
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
$this->postJson('/api/v1/auth/token', [
|
||||
'grant_type' => 'password',
|
||||
'username' => 'MALLORY@example.com',
|
||||
'password' => 'guess-7',
|
||||
])->assertTooManyRequests();
|
||||
|
||||
$this->postJson('/api/v1/auth/token', [
|
||||
'grant_type' => 'password',
|
||||
'username' => 'ada@example.com',
|
||||
'password' => 'secret',
|
||||
])->assertOk();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void
|
||||
{
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Panel;
|
||||
|
||||
use Database\Seeders\RolePermissionSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\FakesPireon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* The one banner that has to be right in the one direction that matters.
|
||||
*
|
||||
* Nobody edits a local clone carefully because they mistake it for
|
||||
* production — the expensive mistake runs the other way. So the only
|
||||
* environment that stays silent is `local`; every other one says so, and
|
||||
* production says so the loudest.
|
||||
*/
|
||||
class EnvironmentBannerTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
$this->actingAs($this->userWithRoles('admin'));
|
||||
}
|
||||
|
||||
protected function setEnvironment(string $environment): void
|
||||
{
|
||||
$this->app['env'] = $environment;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function nothing_is_shown_in_a_local_install(): void
|
||||
{
|
||||
$this->setEnvironment('local');
|
||||
|
||||
$this->get('/admin')
|
||||
->assertOk()
|
||||
->assertDontSee(__('panel.environment.production'))
|
||||
->assertDontSeeText('bg-danger-600', escape: false);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function production_says_so_in_the_loudest_colour(): void
|
||||
{
|
||||
$this->setEnvironment('production');
|
||||
|
||||
$this->get('/admin')
|
||||
->assertOk()
|
||||
->assertSee(__('panel.environment.production'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function anything_else_names_itself(): void
|
||||
{
|
||||
$this->setEnvironment('staging');
|
||||
|
||||
$this->get('/admin')
|
||||
->assertOk()
|
||||
->assertSee(__('panel.environment.other', ['name' => 'staging']));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user