Compare commits

...

16 Commits

Author SHA1 Message Date
82ef2b38dd Manage projects, members and services through the API
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
/api/v1/projects, /members and /services: create, list, show, update,
delete and restore, with the panel's own rules — so the MCP server can
load the collective's work, people and offer without anyone retyping
them into forms. The MCP server picks the tools up from openapi.yaml.

- Relations travel as ids and are replaced whole when sent, left alone
  when not: a project's technology_ids, service_ids and members with
  each one's role; a member's technology_ids; a service's
  technology_ids and project_ids. Record and relations are saved in
  one transaction.
- Publishing stays a permission of its own. Without projects.publish
  or services.publish, published_at, unpublish_at and is_published are
  refused — the fields the panel disables for the same people.
- MemberPolicy still lets someone edit their own profile.
- Uploads accept `for: projects` and `for: members`, landing where the
  panel's own upload fields put them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:07:20 +02:00
6b67c7afd4 Give the test suite the memory it now needs
The full run died with "Premature end of PHP process" on machines whose
php.ini keeps PHP's usual 128M: no single test needs more, but the
panel tests boot Filament again and again in one process, and the
suite outgrew it. Always in a panel test, which is why it looked like
one of them was flaky. Stated in phpunit.xml so it runs the same
everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:07:19 +02:00
7e672a5e62 Serve uploaded SVGs under their CSP, and identify the image fetcher
The Content-Security-Policy meant for stored SVGs never applied: in
Caddy a `*` in the middle of a path does not cross a `/`, so
`path /storage/*.svg` only matched files directly under /storage, and
every upload lives in a directory below it. Two matchers, which Caddy
ANDs, cover any depth.

RemoteImage fetched with Guzzle's anonymous default User-Agent, which
Wikimedia answers with 403. It now sends `<app>/1.0 (+<app url>; media
fetcher)`, the identification such hosts ask of automated clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:43:59 +02:00
ac546eba2b Manage technologies through the API, with SVG logos
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
/api/v1/technologies is a full CRUD with the panel's own rules: name and
slug are plain strings, the prose is translatable, `radar_ring: null`
takes one off the radar, and TechnologyPolicy still refuses to delete
one a project, member or call uses. The MCP server picks the tools up
from openapi.yaml on its own.

A logo is an SVG, so POST /v1/media now accepts one — sanitized with
enshrined/svg-sanitize (scripts, event handlers and remote references
removed), and served by Caddy under a Content-Security-Policy that
would stop a script regardless. `for` (pages or technologies) says what
an upload is for, which picks the directory the panel uses for it and
the permission it takes.

Creating a technology without a category answered 500: the column's
default never reached the model, so the response read a null enum. The
controller now states the default, as the panel does.

Beside a file, the MCP upload tool no longer demands the JSON body's
`url`: the body then carries the upload's other fields, such as `for`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:49:00 +02:00
a49816eb46 Upload images through the API, and from the MCP server
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
POST /api/v1/media stores an image where the panel keeps a page's and
answers with the path to send as `featured_image`. Without it the MCP
server could create a page but never give it a picture. The file goes
through Media::for(), so it is processed, served and pruned like one
uploaded in the panel.

Two ways in, one per request: a multipart `file`, or JSON with a `url`
the API downloads itself. JPEG, PNG, WebP, GIF or AVIF only, decided
from the bytes; no SVG, which is a document that can carry script.

The URL is fetched from inside the stack's network, so RemoteImage
guards it: HTTPS only, every resolved address must be public, the
connection is pinned to the checked address with CURLOPT_RESOLVE so a
changing DNS answer cannot swap in a private one, redirects are not
followed, and the body is read to a ceiling whatever its headers say.

Uploading needs permission to write pages. MediaPolicy::create stays
false, so the panel gains no free-standing "new file" button.

In the MCP server a multipart binary field becomes `<field>_path` over
stdio: the server reads the file from the caller's disk and sends it
as multipart, instead of the model pushing base64 through its context.
Over HTTP the server's disk is not the caller's, so only `body.url` is
offered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:16:33 +02:00
daf6098203 Load a page's image record in the admin API
PageResource only fills `featured_image_media` from the `featuredMedia`
relation when it is loaded, and PageController never loaded it, so every
admin response carried the image's URL with no dimensions, srcset or
placeholder even when the processed record existed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:16:31 +02:00
748b485c9b Add an MCP server for the API, deployable next to the application
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
mcp/ exposes both APIs as MCP tools, one per operation in openapi.yaml,
named after the Laravel route each lands on (public_projects_index,
admin_pages_update, ...). Nothing is written by hand: the tools are
derived from the document the target application serves, which the
contract tests already hold to the truth.

Two transports over one core:

- stdio (`node mcp/server.mjs`), registered in .mcp.json, signing in
  with credentials from the environment and refreshing the token.
- Streamable HTTP (`--http`), stateless, deployed as the landing-mcp
  service. It holds no credentials: each request brings its caller's
  Pireon Basic credentials or bearer token, so the API applies that
  person's permissions. Sessions live in memory keyed by a hash,
  refused credentials are remembered for a minute, and the spec is
  reloaded every minute so new endpoints appear without a redeploy.

Left out on purpose: the token exchange, telemetry, and the contact and
apply forms, which notify real people. The preview secret is never sent
for anonymous callers, and is unset in the deployment by default.

A CI job runs the Node tests, including the HTTP mode end to end
against a fake application.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:58:14 +02:00
5ca084bd4a Throttle the token endpoint per account and address, not per address
POST /api/v1/auth/token was limited to 6 requests a minute per IP. The
deployed MCP server signs every user in from a single container, so
under a per-IP budget one person's mistyped password, or a stranger's
guesses, would lock every other user out of it.

The named `auth-token` limiter keys on the lower-cased username (or the
refresh token) plus the address, still 6 a minute, with a 30-a-minute
ceiling per address to bound how many accounts one client can try.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:58:13 +02:00
5558db6d56 Rename the app service to landing-app to avoid a Dokploy network collision
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Compose aliases a service to its own name on every network it joins,
including the shared external dokploy-network. A service named plain
"app" collided with another project's equally generic "app" on that
same network, so its containers started resolving "app" to this one
instead — breaking oauth-iam's PHP-FPM upstream whenever both stacks
were up together.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 00:09:00 +02:00
1e925a2b07 add port
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 23:47:24 +02:00
080c4238e8 remove port
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 23:43:43 +02:00
ffde884f5b fix docker
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 23:27:07 +02:00
2a58833190 fix track
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 23:15:23 +02:00
c30863ab9b fix panel
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 22:48:56 +02:00
2cca79cf04 fix docker
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 22:30:18 +02:00
033cec6573 fix docker
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-22 22:03:30 +02:00
63 changed files with 6548 additions and 174 deletions

View File

@@ -20,3 +20,6 @@ database/*.sqlite-journal
.phpunit.result.cache .phpunit.result.cache
docs/*.pdf docs/*.pdf
public/storage public/storage
# Runs on a developer machine, next to Claude Code, never in the image.
mcp

View File

@@ -2,10 +2,17 @@
# Production environment for the Docker stack. # Production environment for the Docker stack.
# #
# cp .env.production.example .env.production # 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, # Everything the containers need is here; docker-compose.yml sets the database,
# cache, session and queue drivers itself so they cannot drift. # 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" APP_NAME="Pireon Admin"
@@ -116,3 +123,18 @@ MAIL_USERNAME=
MAIL_PASSWORD= MAIL_PASSWORD=
MAIL_FROM_ADDRESS="admin@example.org" MAIL_FROM_ADDRESS="admin@example.org"
MAIL_FROM_NAME="${APP_NAME}" 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=

View File

@@ -79,3 +79,24 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: docker compose -f docker-compose.test.yml run --rm test - 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
View File

@@ -27,3 +27,4 @@ _ide_helper.php
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
/mcp/node_modules

15
.mcp.json Normal file
View 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:-}"
}
}
}
}

View File

@@ -20,13 +20,13 @@ WORKDIR /app
# sideways staying sideways. pcntl lets the queue worker shut down cleanly # sideways staying sideways. pcntl lets the queue worker shut down cleanly
# instead of being killed mid-job. # instead of being killed mid-job.
RUN install-php-extensions \ RUN install-php-extensions \
pdo_pgsql \ pdo_pgsql \
gd \ gd \
exif \ exif \
intl \ intl \
zip \ zip \
pcntl \ pcntl \
opcache \ opcache \
&& apt-get update \ && apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client \ && apt-get install -y --no-install-recommends postgresql-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -49,11 +49,11 @@ COPY composer.json composer.lock ./
# application cannot boot before its code is here. They are run explicitly in # application cannot boot before its code is here. They are run explicitly in
# the runtime stage instead, where everything exists. # the runtime stage instead, where everything exists.
RUN composer install \ RUN composer install \
--no-dev \ --no-dev \
--no-scripts \ --no-scripts \
--no-autoloader \ --no-autoloader \
--prefer-dist \ --prefer-dist \
--no-interaction --no-interaction
COPY . . COPY . .
@@ -93,7 +93,7 @@ RUN touch .env \
FROM base AS runtime FROM base AS runtime
LABEL org.opencontainers.image.title="Pireon Admin" \ LABEL org.opencontainers.image.title="Pireon Admin" \
org.opencontainers.image.description="Admin panel and REST API for the collective" org.opencontainers.image.description="Admin panel and REST API for the collective"
COPY docker/Caddyfile /etc/caddy/Caddyfile COPY docker/Caddyfile /etc/caddy/Caddyfile
@@ -113,9 +113,9 @@ RUN php artisan package:discover --ansi \
RUN chown -R www-data:www-data storage bootstrap/cache RUN chown -R www-data:www-data storage bootstrap/cache
ENV SERVER_NAME=:80 ENV SERVER_NAME=:8080
EXPOSE 80 EXPOSE 8080
ENTRYPOINT ["entrypoint"] ENTRYPOINT ["entrypoint"]

180
README.md
View File

@@ -390,14 +390,52 @@ Filtros para las cuatro preguntas que no se pueden responder en ningún otro
sitio: qué está **sin procesar**, qué **falló**, qué **no usa nadie** (lo mismo sitio: qué está **sin procesar**, qué **falló**, qué **no usa nadie** (lo mismo
que se llevaría `content:prune --media`) y qué **ya no está en disco**. que se llevaría `content:prune --media`) y qué **ya no está en disco**.
No se sube desde ahí: los archivos llegan al subirlos en una ficha. Y **no se No se sube desde ahí: los archivos llegan al subirlos en una ficha (o por la
puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que API, abajo). Y **no se puede borrar uno que algo esté usando** — dejaría una imagen rota en la web, que
es justo de lo que se queja la página de salud. El botón solo aparece en los es justo de lo que se queja la página de salud. El botón solo aparece en los
huérfanos. huérfanos.
«Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la «Reprocesar todo» es la respuesta a «he cambiado los anchos, ¿y ahora?»: la
tabla es derivada, así que reconstruirla siempre es seguro. tabla es derivada, así que reconstruirla siempre es seguro.
### Subir por la API
`POST /api/v1/media` es el campo de subida de una ficha, sin la ficha: guarda la
imagen donde el panel pone las de ese tipo de ficha y contesta con el `path` que
se manda como `featured_image` de una página o `logo` de una tecnología. `for`
dice para qué es (`pages`, por defecto, o `technologies`), y con ello la carpeta
y el permiso que hace falta. Existe para el [MCP](#servidor-mcp), que sin él
podía crear una página pero nunca ponerle una foto. Pasa por `Media::for()`,
así que se procesa, se sirve y se poda igual que una subida desde el panel.
Dos formas de entrada, una por petición: **multipart** con `file`, o **JSON**
con una `url` que la API descarga ella misma. JPEG, PNG, WebP, GIF, AVIF y SVG,
decidido por los bytes y no por el nombre ni el tipo declarado. 20 MB como mucho
(`MEDIA_UPLOAD_MAX_KB`).
**El SVG entra, pero nunca tal cual**: es lo que es un logo, y también un
documento que puede llevar script servido desde este dominio. Pasa antes por
[`enshrined/svg-sanitize`](https://github.com/darylldoyle/svg-sanitizer), que
quita scripts, manejadores de eventos y referencias a cualquier cosa fuera del
fichero; y Caddy sirve los `/storage/*.svg` con una `Content-Security-Policy`
que pararía un script igualmente si se colara.
**La URL es la parte delicada**, porque la petición la hace este servidor desde
dentro de la red del stack: sin control, sería una forma de pedirle
`http://postgres:5432` o el servicio de metadatos de la nube.
[`RemoteImage`](app/Services/Media/RemoteImage.php) exige HTTPS, resuelve el
dominio y **rechaza si una sola de sus direcciones no es pública** (privadas,
reservadas, loopback, link-local, 100.64/10, IPv4 mapeada en IPv6), **fija la
conexión a la dirección comprobada** con `CURLOPT_RESOLVE` para que un DNS que
cambie entre la comprobación y la petición no cuele otra, **no sigue
redirecciones** y deja de leer al pasar del límite, diga lo que diga
`Content-Length`.
El permiso es escribir el tipo de ficha de `for` (`pages.create` o
`pages.update`, `technologies.create` o…), no ninguno de `media.*`: esos siempre describieron cuidar ficheros que ya existen, y
`MediaPolicy::create` sigue siendo `false` para que el panel no gane un botón de
«nuevo fichero» suelto.
### Galerías ### Galerías
Un proyecto tiene además una galería —capturas, el antes y el después— que se Un proyecto tiene además una galería —capturas, el antes y el después— que se
@@ -1225,8 +1263,21 @@ POST /api/v1/pages/{page}/restore
GET /api/v1/users PATCH para roles, is_active, locale GET /api/v1/users PATCH para roles, is_active, locale
GET /api/v1/roles solo lectura GET /api/v1/roles solo lectura
GET /api/v1/roles/catalogue catálogo de permisos agrupado GET /api/v1/roles/catalogue catálogo de permisos agrupado
/api/v1/projects CRUD + restore; relaciones por ids:
technology_ids, service_ids, members[{id, role}]
/api/v1/members CRUD + restore; links (etiqueta → URL), technology_ids
/api/v1/services CRUD + restore; technology_ids, project_ids
/api/v1/technologies CRUD completo; filtros: category,
radar_ring (none = fuera del radar), search
POST /api/v1/media subir una imagen (fichero o URL)
``` ```
Publicar sigue siendo un permiso aparte, como en el panel (donde esos campos
salen desactivados): sin `projects.publish` o `services.publish`, la API rechaza
`published_at`, `unpublish_at` e `is_published`, así que quien puede escribir
algo no puede sacarlo a la web solo. Las relaciones enviadas sustituyen a las
que había, y las no enviadas no se tocan; todo se guarda en una transacción.
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del `GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
cliente en vez de comprobar nombres de rol. cliente en vez de comprobar nombres de rol.
@@ -1332,6 +1383,105 @@ 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 viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su
prosa, y un tipo a secas no la conserva. 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.
- **Subir imágenes** (`admin_media_store`): en local el modelo pasa una ruta de
tu disco (`file_path`) y el servidor la lee y la sube como multipart —en
base64 una foto normal serían cientos de miles de tokens—. **Desplegado no hay
`file_path`**: el servidor leería su propio disco a petición de cualquiera, así
que solo se ofrece `body.url`, que descarga la API con las protecciones de
[Subir por la API](#subir-por-la-api).
`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 ## Comandos
```bash ```bash
@@ -1364,7 +1514,7 @@ php artisan site:maintenance --up
## Tests ## Tests
```bash ```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) En cada push y cada pull request, [`.github/workflows/tests.yml`](.github/workflows/tests.yml)
@@ -1458,7 +1608,7 @@ párrafo aparte sin relación programática con él.
## Estructura ## 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 docker-compose.test.yml la suite contra Postgres, desechable
Dockerfile imagen única para los tres contenedores PHP Dockerfile imagen única para los tres contenedores PHP
docker/ Caddyfile, php.ini y entrypoint docker/ Caddyfile, php.ini y entrypoint
@@ -1491,6 +1641,7 @@ config/site.php frontend público, preview, caché, rate
config/media.php disco, anchos y formatos de imagen config/media.php disco, anchos y formatos de imagen
config/git.php proveedores de git y etiquetas de issues config/git.php proveedores de git y etiquetas de issues
public/openapi.yaml especificación de ambas APIs public/openapi.yaml especificación de ambas APIs
mcp/ servidor MCP (stdio y HTTP) generado desde la anterior
``` ```
## Despliegue con Docker ## Despliegue con Docker
@@ -1499,16 +1650,29 @@ Cuatro contenedores y una base de datos:
```bash ```bash
cp .env.production.example .env.production 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 docker compose --env-file .env.production up -d --build
# el primer administrador, igual que en local # 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 | | 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 | | `worker` | procesa imágenes y manda los avisos del buzón |
| `scheduler` | cierra convocatorias, barre imágenes pendientes, sincroniza repos | | `scheduler` | cierra convocatorias, barre imágenes pendientes, sincroniza repos |
| `postgres` | la base de datos | | `postgres` | la base de datos |
@@ -1587,7 +1751,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 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 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` 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. y ahí no está ni el runner de tests.

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\MediaRequest;
use App\Http\Resources\MediaResource;
use App\Models\Media;
use App\Services\Media\RemoteImage;
use enshrined\svgSanitize\Sanitizer;
use finfo;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/**
* POST /api/v1/media
*
* Stores an image and answers with the path a record points at — what the
* panel's upload field does on a form, without the form. It exists for the MCP
* server, which otherwise could create a page but never give it a picture.
*
* The file lands where the panel puts a page's images and goes through
* `Media::for()`, so it is processed, served and eventually pruned exactly
* like one uploaded there.
*/
class MediaController extends Controller
{
/**
* Extension by detected type. Chosen from the bytes, never from the name
* the client sent.
*
* @var array<string, string>
*/
protected const EXTENSIONS = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'image/gif' => 'gif',
'image/avif' => 'avif',
'image/svg+xml' => 'svg',
];
public function store(MediaRequest $request, RemoteImage $remote): JsonResponse
{
$for = (string) $request->input('for', 'pages');
$this->authorize('upload', [Media::class, $for]);
$field = $request->hasFile('file') ? 'file' : 'url';
$contents = $field === 'file'
? (string) $request->file('file')->get()
: $remote->fetch((string) $request->string('url'));
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($contents) ?: null;
if (! in_array($mime, (array) config('media.upload.mimes'), true)) {
$this->refuse($field);
}
if ($mime === 'image/svg+xml') {
$contents = $this->sanitize($contents) ?? $this->refuse($field);
// A vector has no pixel size; the processor copies it through.
$size = [null, null];
} else {
// The type is read from the magic bytes; this catches a file that
// has them but does not decode, which nobody could render either.
$size = @getimagesizefromstring($contents) ?: $this->refuse($field);
}
$path = config("media.upload.for.{$for}").'/'.Str::random(40).'.'.self::EXTENSIONS[$mime];
Storage::disk((string) config('media.disk'))->put($path, $contents);
$media = Media::for($path);
// What is already known, so the answer carries dimensions straight
// away rather than after the queue has run. The processor writes the
// same values, and the rest, when it does.
if (! $media->isProcessed()) {
$media->forceFill([
'mime' => $mime,
'size' => strlen($contents),
'width' => $size[0],
'height' => $size[1],
])->save();
}
return MediaResource::make($media->fresh())->response()->setStatusCode(201);
}
/**
* An SVG with what could run taken out: scripts, event handlers, and
* references to anything outside the file. Null when what is left is not
* an SVG at all.
*/
protected function sanitize(string $svg): ?string
{
$sanitizer = new Sanitizer;
$sanitizer->removeRemoteReferences(true);
$clean = $sanitizer->sanitize($svg);
return is_string($clean) && str_contains($clean, '<svg') ? $clean : null;
}
protected function refuse(string $field): never
{
throw ValidationException::withMessages([$field => [__('rules.media.not_an_image')]]);
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\MemberRequest;
use App\Http\Resources\MemberResource;
use App\Models\Member;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
/**
* The people in the collective, as the panel edits them. MemberPolicy lets
* someone edit their own profile without `members.update`, as in the panel.
*/
class MemberController extends Controller
{
protected const WITH = ['avatarMedia', 'technologies', 'projects'];
/**
* Filters: search (name), is_public, trashed.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Member::class);
$members = Member::query()
->with(self::WITH)
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
->when($request->has('is_public'), fn ($q) => $q->where('is_public', $request->boolean('is_public')))
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
->orderBy('position')
->orderBy('name')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return MemberResource::collection($members);
}
public function store(MemberRequest $request): JsonResponse
{
$this->authorize('create', Member::class);
return MemberResource::make($this->save(new Member, $request))->response()->setStatusCode(201);
}
public function show(Member $member): MemberResource
{
$this->authorize('view', $member);
return MemberResource::make($member->load(self::WITH));
}
public function update(MemberRequest $request, Member $member): MemberResource
{
$this->authorize('update', $member);
return MemberResource::make($this->save($member, $request));
}
public function destroy(Member $member): JsonResponse
{
$this->authorize('delete', $member);
$member->delete();
return response()->json(status: 204);
}
public function restore(Member $member): MemberResource
{
$this->authorize('restore', $member);
$member->restore();
return MemberResource::make($member->load(self::WITH));
}
protected function save(Member $member, MemberRequest $request): Member
{
$data = $request->validated();
DB::transaction(function () use ($member, $data): void {
$member->fill(TranslatablePayload::merge(
$member,
collect($data)->except('technology_ids')->all(),
$member->translatable,
));
$member->save();
if (array_key_exists('technology_ids', $data)) {
$member->technologies()->sync($data['technology_ids']);
}
});
return $member->load(self::WITH);
}
}

View File

@@ -30,7 +30,7 @@ class PageController extends Controller
$locale = app()->getLocale(); $locale = app()->getLocale();
$pages = Page::query() $pages = Page::query()
->with(['category', 'author']) ->with(['category', 'author', 'featuredMedia'])
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('category_id'), fn ($q) => $q->where('category_id', $request->integer('category_id'))) ->when($request->filled('category_id'), fn ($q) => $q->where('category_id', $request->integer('category_id')))
->when($request->has('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured'))) ->when($request->has('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured')))
@@ -67,7 +67,7 @@ class PageController extends Controller
$page->save(); $page->save();
return PageResource::make($page->load(['category', 'author'])) return PageResource::make($page->load(['category', 'author', 'featuredMedia']))
->response() ->response()
->setStatusCode(201); ->setStatusCode(201);
} }
@@ -76,7 +76,7 @@ class PageController extends Controller
{ {
$this->authorize('view', $page); $this->authorize('view', $page);
return PageResource::make($page->load(['category', 'author'])); return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
} }
public function update(PageRequest $request, Page $page): PageResource public function update(PageRequest $request, Page $page): PageResource
@@ -91,7 +91,7 @@ class PageController extends Controller
$page->save(); $page->save();
return PageResource::make($page->load(['category', 'author'])); return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
} }
public function destroy(Page $page): JsonResponse public function destroy(Page $page): JsonResponse
@@ -109,7 +109,7 @@ class PageController extends Controller
$page->restore(); $page->restore();
return PageResource::make($page->load(['category', 'author'])); return PageResource::make($page->load(['category', 'author', 'featuredMedia']));
} }
/** /**

View File

@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\ProjectRequest;
use App\Http\Resources\ProjectResource;
use App\Models\Project;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
/**
* Projects, as the panel edits them — so the MCP server can load the
* collective's work without anyone copying it into a form by hand.
*/
class ProjectController extends Controller
{
/** Relations every answer carries, so a client can PATCH back what it read. */
protected const WITH = ['coverMedia', 'technologies', 'services', 'members'];
/**
* Filters: status, search (title in the active language), trashed.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Project::class);
$locale = app()->getLocale();
$projects = Project::query()
->with(self::WITH)
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
->orderBy('position')
->orderByDesc('created_at')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return ProjectResource::collection($projects);
}
public function store(ProjectRequest $request): JsonResponse
{
$this->authorize('create', Project::class);
$project = $this->save(new Project, $request);
return ProjectResource::make($project)->response()->setStatusCode(201);
}
public function show(Project $project): ProjectResource
{
$this->authorize('view', $project);
return ProjectResource::make($project->load(self::WITH));
}
public function update(ProjectRequest $request, Project $project): ProjectResource
{
$this->authorize('update', $project);
return ProjectResource::make($this->save($project, $request));
}
public function destroy(Project $project): JsonResponse
{
$this->authorize('delete', $project);
$project->delete();
return response()->json(status: 204);
}
public function restore(Project $project): ProjectResource
{
$this->authorize('restore', $project);
$project->restore();
return ProjectResource::make($project->load(self::WITH));
}
/**
* The record and its relations in one transaction: a project that saved
* but lost its team halfway would be worse than one that did not save.
*/
protected function save(Project $project, ProjectRequest $request): Project
{
$data = $request->validated();
DB::transaction(function () use ($project, $data): void {
$project->fill(TranslatablePayload::merge(
$project,
collect($data)->except(['technology_ids', 'service_ids', 'members'])->all(),
$project->translatable,
));
$project->save();
if (array_key_exists('technology_ids', $data)) {
$project->technologies()->sync($data['technology_ids']);
}
if (array_key_exists('service_ids', $data)) {
$project->services()->sync($data['service_ids']);
}
if (array_key_exists('members', $data)) {
$project->members()->sync(collect($data['members'])->mapWithKeys(
fn (array $member) => [$member['id'] => ['role' => $member['role'] ?? null]],
)->all());
}
});
return $project->load(self::WITH);
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\ServiceRequest;
use App\Http\Resources\ServiceResource;
use App\Models\Service;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
/**
* What the collective offers, as the panel edits it.
*/
class ServiceController extends Controller
{
protected const WITH = ['technologies', 'projects'];
/**
* Filters: is_published, search (title in the active language), trashed.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Service::class);
$locale = app()->getLocale();
$services = Service::query()
->with(self::WITH)
->when($request->has('is_published'), fn ($q) => $q->where('is_published', $request->boolean('is_published')))
->when($request->filled('search'), fn ($q) => $q->where("title->{$locale}", 'like', '%'.$request->string('search').'%'))
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
->orderBy('position')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return ServiceResource::collection($services);
}
public function store(ServiceRequest $request): JsonResponse
{
$this->authorize('create', Service::class);
return ServiceResource::make($this->save(new Service, $request))->response()->setStatusCode(201);
}
public function show(Service $service): ServiceResource
{
$this->authorize('view', $service);
return ServiceResource::make($service->load(self::WITH));
}
public function update(ServiceRequest $request, Service $service): ServiceResource
{
$this->authorize('update', $service);
return ServiceResource::make($this->save($service, $request));
}
public function destroy(Service $service): JsonResponse
{
$this->authorize('delete', $service);
$service->delete();
return response()->json(status: 204);
}
public function restore(Service $service): ServiceResource
{
$this->authorize('restore', $service);
$service->restore();
return ServiceResource::make($service->load(self::WITH));
}
protected function save(Service $service, ServiceRequest $request): Service
{
$data = $request->validated();
DB::transaction(function () use ($service, $data): void {
$service->fill(TranslatablePayload::merge(
$service,
collect($data)->except(['technology_ids', 'project_ids'])->all(),
$service->translatable,
));
$service->save();
if (array_key_exists('technology_ids', $data)) {
$service->technologies()->sync($data['technology_ids']);
}
if (array_key_exists('project_ids', $data)) {
$service->projects()->sync($data['project_ids']);
}
});
return $service->load(self::WITH);
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\TechnologyCategory;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\TechnologyRequest;
use App\Http\Resources\TechnologyResource;
use App\Models\Technology;
use App\Support\TranslatablePayload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* The technologies the collective uses, and their place on the radar.
*
* Same rules as the panel: TechnologyPolicy refuses to delete one a project,
* member or call still points at.
*/
class TechnologyController extends Controller
{
/**
* Filters: category, radar_ring (`none` for those off the radar), search.
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Technology::class);
$technologies = Technology::query()
->with('logoMedia')
->withCount('projects')
->when($request->filled('category'), fn ($q) => $q->where('category', $request->string('category')))
->when($request->filled('radar_ring'), fn ($q) => $request->string('radar_ring')->toString() === 'none'
? $q->whereNull('radar_ring')
: $q->where('radar_ring', $request->string('radar_ring')))
->when($request->filled('search'), fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%'))
->orderBy('position')
->orderBy('name')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return TechnologyResource::collection($technologies);
}
public function store(TechnologyRequest $request): JsonResponse
{
$this->authorize('create', Technology::class);
$technology = new Technology;
$technology->fill(TranslatablePayload::merge($technology, $request->validated(), $technology->translatable));
// The column's default, and the panel's, stated here so the model
// knows it too: otherwise the answer to a create without a category
// would be missing the one the row was given.
$technology->category ??= TechnologyCategory::Tool;
$technology->save();
return TechnologyResource::make($this->loaded($technology))->response()->setStatusCode(201);
}
public function show(Technology $technology): TechnologyResource
{
$this->authorize('view', $technology);
return TechnologyResource::make($this->loaded($technology));
}
public function update(TechnologyRequest $request, Technology $technology): TechnologyResource
{
$this->authorize('update', $technology);
$technology->fill(TranslatablePayload::merge($technology, $request->validated(), $technology->translatable));
$technology->save();
return TechnologyResource::make($this->loaded($technology));
}
public function destroy(Technology $technology): JsonResponse
{
$this->authorize('delete', $technology);
$technology->delete();
return response()->json(status: 204);
}
protected function loaded(Technology $technology): Technology
{
return $technology->load('logoMedia')->loadCount('projects');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1\Concerns;
use Illuminate\Validation\Validator;
/**
* Publishing is a permission of its own, as in the panel — where these fields
* are disabled for whoever lacks it. Here they are refused, so someone who may
* write a record still cannot put it on the site, or take it off, alone.
*/
trait GuardsPublication
{
/**
* @param array<int, string> $fields
*/
protected function guardPublication(Validator $validator, string $permission, array $fields): void
{
$validator->after(function (Validator $validator) use ($permission, $fields): void {
if ($this->user()?->can($permission)) {
return;
}
foreach ($fields as $field) {
if ($this->has($field)) {
$validator->errors()->add($field, __('rules.publication.forbidden'));
}
}
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
/**
* An image arrives one of two ways: as the request's `file` (multipart), or as
* a `url` the API downloads itself (JSON). Exactly one.
*
* What the declared type says is only a first filter. The controller looks at
* the bytes, because a client names the type of what it sends and a URL's
* server names the type of what it answers — and neither has to be telling
* the truth.
*/
class MediaRequest extends FormRequest
{
/**
* Authorisation is the controller's, as everywhere else in this API — and
* it has to happen before a URL is fetched, which is after validation.
*/
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'file' => [
'required_without:url',
'prohibits:url',
'file',
'max:'.(int) config('media.upload.max_kb'),
'mimetypes:'.implode(',', (array) config('media.upload.mimes')),
],
'url' => ['required_without:file', 'string', 'url:https', 'max:2048'],
'for' => ['sometimes', 'string', 'in:'.implode(',', array_keys((array) config('media.upload.for')))],
];
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Models\Member;
use App\Rules\Translatable;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's member form, as rules. A name is a person's, the same in every
* language, and so is the slug built from it; only the headline and the bio
* are translatable.
*
* `links` is a label → URL map (`{"github": "https://…"}`). `technology_ids`
* is replaced whole when sent. Which projects someone is on is set from the
* project, where the role belongs.
*/
class MemberRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$member = $this->route('member');
$creating = ! $member instanceof Member;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'name' => [$sometimes, 'string', 'max:255'],
'slug' => [
$sometimes,
'string',
'max:255',
'alpha_dash:ascii',
Rule::unique('members', 'slug')->ignore($member?->id),
],
'headline' => ['sometimes', 'nullable', new Translatable(max: 255)],
'bio' => ['sometimes', 'nullable', new Translatable],
'avatar' => ['sometimes', 'nullable', 'string', 'max:2048'],
// Never shown on the site; the panel keeps it for the collective.
'email' => ['sometimes', 'nullable', 'email', 'max:255'],
'github_username' => ['sometimes', 'nullable', 'string', 'max:120'],
'links' => ['sometimes', 'nullable', 'array', 'max:10'],
'links.*' => ['nullable', 'url', 'max:2048'],
'is_public' => ['sometimes', 'boolean'],
'is_founder' => ['sometimes', 'boolean'],
'joined_at' => ['sometimes', 'nullable', 'date'],
'left_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:joined_at'],
'position' => ['sometimes', 'integer', 'min:0'],
'technology_ids' => ['sometimes', 'array'],
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
];
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\ProjectStatus;
use App\Http\Requests\Api\V1\Concerns\GuardsPublication;
use App\Models\Project;
use App\Rules\Translatable;
use App\Rules\UniqueTranslatedSlug;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
/**
* The panel's project form, as rules. On update every field is optional
* (PATCH semantics) but keeps the same shape.
*
* Relations travel as ids and are replaced whole when sent: `technology_ids`,
* `service_ids`, and `members` with each one's role on the project.
*/
class ProjectRequest extends FormRequest
{
use GuardsPublication;
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$project = $this->route('project');
$creating = ! $project instanceof Project;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
'slug' => [
$sometimes,
new Translatable(requireDefaultLocale: $creating, max: 255),
new UniqueTranslatedSlug(Project::class, $project?->id),
],
'tagline' => ['sometimes', 'nullable', new Translatable(max: 300)],
'description' => ['sometimes', 'nullable', new Translatable],
'decisions' => ['sometimes', 'nullable', new Translatable],
'postmortem' => ['sometimes', 'nullable', new Translatable],
'meta_title' => ['sometimes', 'nullable', new Translatable(max: 60)],
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
'status' => ['sometimes', Rule::enum(ProjectStatus::class)],
'is_featured' => ['sometimes', 'boolean'],
'needs_help' => ['sometimes', 'boolean'],
'demo_url' => ['sometimes', 'nullable', 'url', 'max:2048'],
'cover_image' => ['sometimes', 'nullable', 'string', 'max:2048'],
'cover_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)],
'started_at' => ['sometimes', 'nullable', 'date'],
'ended_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:started_at'],
'published_at' => ['sometimes', 'nullable', 'date'],
'unpublish_at' => ['sometimes', 'nullable', 'date', 'after:published_at'],
'position' => ['sometimes', 'integer', 'min:0'],
'client_id' => ['sometimes', 'nullable', 'integer', Rule::exists('clients', 'id')->whereNull('deleted_at')],
'lead_member_id' => ['sometimes', 'nullable', 'integer', Rule::exists('members', 'id')->whereNull('deleted_at')],
'technology_ids' => ['sometimes', 'array'],
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
'service_ids' => ['sometimes', 'array'],
'service_ids.*' => ['integer', 'distinct', Rule::exists('services', 'id')->whereNull('deleted_at')],
'members' => ['sometimes', 'array'],
'members.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')],
'members.*.role' => ['nullable', 'string', 'max:120'],
];
}
public function withValidator(Validator $validator): void
{
$this->guardPublication($validator, 'projects.publish', ['published_at', 'unpublish_at']);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Http\Requests\Api\V1\Concerns\GuardsPublication;
use App\Models\Service;
use App\Rules\Translatable;
use App\Rules\UniqueTranslatedSlug;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
/**
* The panel's service form, as rules. `technology_ids` and `project_ids` are
* replaced whole when sent.
*/
class ServiceRequest extends FormRequest
{
use GuardsPublication;
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$service = $this->route('service');
$creating = ! $service instanceof Service;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
'slug' => [
$sometimes,
new Translatable(requireDefaultLocale: $creating, max: 255),
new UniqueTranslatedSlug(Service::class, $service?->id),
],
'tagline' => ['sometimes', 'nullable', new Translatable(max: 255)],
'description' => ['sometimes', 'nullable', new Translatable],
'meta_title' => ['sometimes', 'nullable', new Translatable(max: 60)],
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
// A name from the icon set the frontend resolves, e.g. `support`.
'icon' => ['sometimes', 'nullable', 'string', 'max:120'],
'is_featured' => ['sometimes', 'boolean'],
'position' => ['sometimes', 'integer', 'min:0'],
'is_published' => ['sometimes', 'boolean'],
'published_at' => ['sometimes', 'nullable', 'date'],
'unpublish_at' => ['sometimes', 'nullable', 'date', 'after:published_at'],
'technology_ids' => ['sometimes', 'array'],
'technology_ids.*' => ['integer', 'distinct', Rule::exists('technologies', 'id')],
'project_ids' => ['sometimes', 'array'],
'project_ids.*' => ['integer', 'distinct', Rule::exists('projects', 'id')->whereNull('deleted_at')],
];
}
public function withValidator(Validator $validator): void
{
$this->guardPublication($validator, 'services.publish', ['is_published', 'published_at', 'unpublish_at']);
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\RadarRing;
use App\Enums\TechnologyCategory;
use App\Models\Technology;
use App\Rules\Translatable;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's technology form, as rules. On update every field is optional
* (PATCH semantics) but keeps the same shape.
*
* `name` and `slug` are plain strings: a technology's name is a proper noun,
* the same in every language. Only the prose about it is translatable.
*/
class TechnologyRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$technology = $this->route('technology');
$creating = ! $technology instanceof Technology;
$sometimes = $creating ? 'required' : 'sometimes';
return [
'name' => [$sometimes, 'string', 'max:120'],
'slug' => [
$sometimes,
'string',
'max:120',
'alpha_dash:ascii',
Rule::unique('technologies', 'slug')->ignore($technology?->id),
],
'description' => ['sometimes', 'nullable', new Translatable(max: 1000)],
'radar_note' => ['sometimes', 'nullable', new Translatable(max: 1000)],
'category' => ['sometimes', Rule::enum(TechnologyCategory::class)],
// Null keeps it off the radar while projects can still be tagged with it.
'radar_ring' => ['sometimes', 'nullable', Rule::enum(RadarRing::class)],
'color' => ['sometimes', 'nullable', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'],
'logo' => ['sometimes', 'nullable', 'string', 'max:255'],
// 255, not the panel's 2048: that is the column's width.
'website' => ['sometimes', 'nullable', 'url', 'max:255'],
'position' => ['sometimes', 'integer', 'min:0'],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Media;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Media
*/
class MediaResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
// What a record stores — `featured_image` on a page — and so the one
// field a caller has to keep.
'path' => $this->path,
'url' => $this->url(),
'mime' => $this->mime,
'size' => $this->size,
'width' => $this->width,
'height' => $this->height,
// False until the queue has derived the smaller versions and the
// placeholder. The image serves either way.
'processed' => $this->isProcessed(),
];
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Member;
use App\Support\ImagePayload;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Member
*/
class MemberResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
return [
'id' => $this->id,
'locale' => $locale,
'name' => $this->name,
'slug' => $this->slug,
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
'avatar' => $this->avatar,
'avatar_media' => ImagePayload::make(
$this->avatar,
$this->resource->relationLoaded('avatarMedia') ? $this->resource->getRelation('avatarMedia') : null,
$this->name,
),
'email' => $this->email,
'github_username' => $this->github_username,
'links' => (object) ($this->links ?? []),
'is_public' => (bool) $this->is_public,
'is_founder' => (bool) $this->is_founder,
'joined_at' => $this->joined_at?->toDateString(),
'left_at' => $this->left_at?->toDateString(),
'position' => $this->position,
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
'projects' => $this->whenLoaded('projects', fn () => $this->projects->map(fn ($project) => [
'id' => $project->id,
'title' => $project->getTranslation('title', $locale),
'role' => $project->pivot->role,
])->values()),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
'translations' => $this->when(
in_array('translations', explode(',', (string) $request->query('with')), true),
fn () => TranslatablePayload::all($this->resource, $this->translatable),
),
];
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Project;
use App\Support\ImagePayload;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Project
*/
class ProjectResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
$resolved = TranslatablePayload::resolve($this->resource, $this->translatable, $locale);
return [
'id' => $this->id,
'locale' => $locale,
...$resolved,
'status' => $this->status->value,
'is_featured' => (bool) $this->is_featured,
'needs_help' => (bool) $this->needs_help,
'demo_url' => $this->demo_url,
// The stored path, which is what a client PATCHes back; what was
// derived from it travels alongside, as on a page.
'cover_image' => $this->cover_image,
'cover_image_media' => ImagePayload::make(
$this->cover_image,
$this->resource->relationLoaded('coverMedia') ? $this->resource->getRelation('coverMedia') : null,
$resolved['cover_image_alt'] ?? null,
),
'started_at' => $this->started_at?->toDateString(),
'ended_at' => $this->ended_at?->toDateString(),
'published_at' => $this->published_at?->toIso8601String(),
'unpublish_at' => $this->unpublish_at?->toIso8601String(),
'position' => $this->position,
'client_id' => $this->client_id,
'lead_member_id' => $this->lead_member_id,
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
'service_ids' => $this->whenLoaded('services', fn () => $this->services->pluck('id')->values()),
'members' => $this->whenLoaded('members', fn () => $this->members->map(fn ($member) => [
'id' => $member->id,
'name' => $member->name,
'role' => $member->pivot->role,
])->values()),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
'translations' => $this->when(
in_array('translations', explode(',', (string) $request->query('with')), true),
fn () => TranslatablePayload::all($this->resource, $this->translatable),
),
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Service;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Service
*/
class ServiceResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
return [
'id' => $this->id,
'locale' => $locale,
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
'icon' => $this->icon,
'is_featured' => (bool) $this->is_featured,
'is_published' => (bool) $this->is_published,
'published_at' => $this->published_at?->toIso8601String(),
'unpublish_at' => $this->unpublish_at?->toIso8601String(),
'position' => $this->position,
'technology_ids' => $this->whenLoaded('technologies', fn () => $this->technologies->pluck('id')->values()),
'project_ids' => $this->whenLoaded('projects', fn () => $this->projects->pluck('id')->values()),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'deleted_at' => $this->deleted_at?->toIso8601String(),
'translations' => $this->when(
in_array('translations', explode(',', (string) $request->query('with')), true),
fn () => TranslatablePayload::all($this->resource, $this->translatable),
),
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Technology;
use App\Support\ImagePayload;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Technology
*/
class TechnologyResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
return [
'id' => $this->id,
'locale' => $locale,
'name' => $this->name,
'slug' => $this->slug,
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
'category' => $this->category->value,
'radar_ring' => $this->radar_ring?->value,
'color' => $this->color,
// The stored path, which is what a client PATCHes back; what was
// derived from it travels alongside, as on a page.
'logo' => $this->logo,
'logo_media' => ImagePayload::make(
$this->logo,
$this->resource->relationLoaded('logoMedia') ? $this->resource->getRelation('logoMedia') : null,
$this->name,
),
'website' => $this->website,
'position' => $this->position,
'projects_count' => $this->whenCounted('projects'),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'translations' => $this->when(
in_array('translations', explode(',', (string) $request->query('with')), true),
fn () => TranslatablePayload::all($this->resource, $this->translatable),
),
];
}
}

View File

@@ -21,6 +21,20 @@ class MediaPolicy extends ResourcePolicy
return false; return false;
} }
/**
* Uploading through the API, which has no form to attach a file to.
*
* Still true to the rule above in spirit: an upload says which kind of
* record it is for — a page, a technology — and whoever may write that kind
* of record may upload its image. Nobody else, whatever their `media.*`
* permissions, which only ever described looking after files that already
* exist.
*/
public function upload(User $user, string $for = 'pages'): bool
{
return $user->can("{$for}.create") || $user->can("{$for}.update");
}
/** /**
* A file something still points at cannot be deleted from here: doing so * A file something still points at cannot be deleted from here: doing so
* would leave a broken image on the public site, which is precisely what * would leave a broken image on the public site, which is precisely what

View File

@@ -54,6 +54,21 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120) RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)
->by($request->user()?->id ?: $request->ip())); ->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 // The public API is a different problem. The public frontend renders
// server-side, so every visitor's page view arrives from the same IP: // 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 // a per-IP budget sized for one person would throttle the entire

View File

@@ -86,14 +86,6 @@ class AdminPanelProvider extends PanelProvider
]) ])
// Language switcher, rendered next to the user menu. // 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( ->renderHook(
PanelsRenderHook::USER_MENU_BEFORE, PanelsRenderHook::USER_MENU_BEFORE,
fn (): string => view('filament.partials.language-switcher')->render(), fn (): string => view('filament.partials.language-switcher')->render(),

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Services\Media;
/**
* Every address a hostname resolves to, IPv4 and IPv6.
*
* Its own class so the tests can answer for hosts that do not exist, and so
* `RemoteImage` can refuse a name that points anywhere private before a single
* byte is requested.
*/
class HostResolver
{
/**
* @return array<int, string>
*/
public function resolve(string $host): array
{
$v4 = gethostbynamel($host) ?: [];
$v6 = array_column(@dns_get_record($host, DNS_AAAA) ?: [], 'ipv6');
return array_values(array_unique([...$v4, ...$v6]));
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Services\Media;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/**
* Downloads an image from a URL someone handed the API, without letting that
* URL reach anything it should not.
*
* The request is made by this server, from inside the stack's network, so an
* unchecked URL is a way to make it fetch `http://postgres:5432`, the cloud
* metadata service, or the application itself. Hence:
*
* - HTTPS only.
* - Every address the host resolves to must be a public one — a single private
* address among several is enough to refuse.
* - The connection is pinned to the address that was checked (CURLOPT_RESOLVE),
* so a DNS answer that changes between the check and the request — DNS
* rebinding — cannot swap in a private one.
* - Redirects are not followed: the target of one would need all of the above
* again, and "use the final address" is an easy thing to ask for.
* - The body is read to a ceiling and no further, whatever the headers claim.
*
* It says who is asking. Hosts such as Wikimedia refuse Guzzle's anonymous
* default outright, and a User-Agent with a contact address is what their
* policies ask of anything that fetches automatically.
*/
class RemoteImage
{
public function __construct(protected HostResolver $resolver) {}
/**
* @return string The file's bytes.
*
* @throws ValidationException With the reason, on the `url` field.
*/
public function fetch(string $url): string
{
$parts = parse_url($url);
$host = $parts['host'] ?? null;
if (($parts['scheme'] ?? null) !== 'https' || blank($host)) {
$this->fail('https_only');
}
$host = trim((string) $host, '[]');
$port = (int) ($parts['port'] ?? 443);
$address = $this->publicAddressFor($host);
$maxBytes = (int) config('media.upload.max_kb') * 1024;
try {
$response = Http::withOptions([
'allow_redirects' => false,
'stream' => true,
'connect_timeout' => 5,
'timeout' => (int) config('media.upload.remote_timeout', 15),
'curl' => [CURLOPT_RESOLVE => [sprintf(
'%s:%d:%s',
$host,
$port,
str_contains($address, ':') ? "[{$address}]" : $address,
)]],
])
->withHeaders(['Accept' => 'image/*'])
->withUserAgent($this->userAgent())
->get($url);
} catch (ConnectionException) {
$this->fail('unreachable');
}
if ($response->redirect()) {
$this->fail('redirect');
}
if ($response->status() !== 200) {
$this->fail('status', ['status' => $response->status()]);
}
if ((int) $response->header('Content-Length') > $maxBytes) {
$this->fail('too_large', ['max' => (int) config('media.upload.max_kb')]);
}
$body = $response->toPsrResponse()->getBody();
$contents = '';
while (! $body->eof()) {
$contents .= $body->read(65536);
if (strlen($contents) > $maxBytes) {
$this->fail('too_large', ['max' => (int) config('media.upload.max_kb')]);
}
}
return $contents;
}
/**
* `pireon-admin/1.0 (+https://admin.example.org; media fetcher)`: a product
* token without spaces, and the installation's own address to reach it by.
*/
protected function userAgent(): string
{
return sprintf(
'%s/1.0 (+%s; media fetcher)',
Str::slug((string) config('app.name')) ?: 'landing',
rtrim((string) config('app.url'), '/'),
);
}
/**
* The address to connect to, once every address the host has is known to
* be public.
*/
protected function publicAddressFor(string $host): string
{
$addresses = filter_var($host, FILTER_VALIDATE_IP) ? [$host] : $this->resolver->resolve($host);
if ($addresses === []) {
$this->fail('unresolvable');
}
foreach ($addresses as $address) {
// GLOBAL_RANGE is the strict one: besides the private and reserved
// blocks it refuses shared (100.64/10), link-local and the
// IPv4-mapped IPv6 spelling of any of them.
if (! filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_GLOBAL_RANGE)) {
$this->fail('private');
}
}
return $addresses[0];
}
/**
* @param array<string, mixed> $replace
*/
protected function fail(string $reason, array $replace = []): never
{
throw ValidationException::withMessages([
'url' => [__("rules.media.remote.{$reason}", $replace)],
]);
}
}

View File

@@ -7,6 +7,7 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"enshrined/svg-sanitize": "^1.0",
"filament/filament": "^5.7", "filament/filament": "^5.7",
"laravel/framework": "^13.17", "laravel/framework": "^13.17",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",

47
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "e9bbf5ad6f9f32f9f506caf56db81710", "content-hash": "6f1347758fcd5a7f4c56b93c83b60453",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -989,6 +989,51 @@
], ],
"time": "2025-03-06T22:45:56+00:00" "time": "2025-03-06T22:45:56+00:00"
}, },
{
"name": "enshrined/svg-sanitize",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/darylldoyle/svg-sanitizer.git",
"reference": "f3300fcd1bbf67d205b52217c75d0f7d6a8c47ff"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/darylldoyle/svg-sanitizer/zipball/f3300fcd1bbf67d205b52217c75d0f7d6a8c47ff",
"reference": "f3300fcd1bbf67d205b52217c75d0f7d6a8c47ff",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-libxml": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^6.5 || ^8.5"
},
"type": "library",
"autoload": {
"psr-4": {
"enshrined\\svgSanitize\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL-2.0-or-later"
],
"authors": [
{
"name": "Daryll Doyle",
"email": "daryll@enshrined.co.uk"
}
],
"description": "An SVG sanitizer for PHP",
"support": {
"issues": "https://github.com/darylldoyle/svg-sanitizer/issues",
"source": "https://github.com/darylldoyle/svg-sanitizer/tree/1.0.0"
},
"time": "2026-09-01T09:35:47+00:00"
},
{ {
"name": "filament/actions", "name": "filament/actions",
"version": "v5.7.6", "version": "v5.7.6",

View File

@@ -76,4 +76,41 @@ return [
// Formats that are copied through untouched. Resampling a vector loses the // Formats that are copied through untouched. Resampling a vector loses the
// point of it. // point of it.
'passthrough_mimes' => ['image/svg+xml', 'image/gif'], 'passthrough_mimes' => ['image/svg+xml', 'image/gif'],
/*
|--------------------------------------------------------------------------
| Uploads through the API
|--------------------------------------------------------------------------
|
| POST /api/v1/media, which the MCP server uses to put an image on a page.
|
| SVG is accepted because it is what a logo is, but never as sent: an SVG
| is a document that can carry script, served from this application's
| domain, and this endpoint also fetches from arbitrary URLs. It goes
| through enshrined/svg-sanitize first — scripts, event handlers and
| remote references removed — and Caddy serves stored SVGs under a
| Content-Security-Policy that would stop a script anyway.
|
| The size ceiling sits under php.ini's `upload_max_filesize` (24M) so the
| answer is a readable 422 rather than PHP silently dropping the file.
|
*/
'upload' => [
'mimes' => ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif', 'image/svg+xml'],
'max_kb' => (int) env('MEDIA_UPLOAD_MAX_KB', 20 * 1024),
// What the image is for, and so where it lands — the same directories
// the panel's own upload fields use — and whose permission it takes.
'for' => [
'pages' => 'pages',
'technologies' => 'technologies',
'projects' => 'projects',
'members' => 'members',
],
// Fetching from a URL: how long to wait, in seconds. The request that
// triggered it is waiting too.
'remote_timeout' => (int) env('MEDIA_REMOTE_TIMEOUT', 15),
],
]; ];

View File

@@ -1,36 +1,59 @@
############################################################################### ###############################################################################
# Production stack. # 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 # 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 one less thing to run, back up and forget to secure. Swapping it in later
# is three environment variables. # 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 # 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 # The web container is the only one that migrates, so a deploy runs them once
# rather than once per replica. # rather than once per replica.
############################################################################### ###############################################################################
name: landing
x-app: &app x-app: &app
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: pireon-admin:latest image: pireon-admin:latest
restart: unless-stopped 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: volumes:
# Uploaded images live here, and they are the one thing in this stack that # Uploaded images live here, and they are the one thing in this stack that
# cannot be rebuilt from the repository. # cannot be rebuilt from the repository.
@@ -39,23 +62,95 @@ x-app: &app
postgres: postgres:
condition: service_healthy condition: service_healthy
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: services:
app: landing-app:
<<: *app <<: *app
environment: environment:
DB_CONNECTION: pgsql <<: *app-env
DB_HOST: postgres
DB_PORT: 5432
CACHE_STORE: database
SESSION_DRIVER: database
QUEUE_CONNECTION: database
# Only here: migrations run once per deploy, not once per container. # Only here: migrations run once per deploy, not once per container.
RUN_MIGRATIONS: "true" RUN_MIGRATIONS: "true"
ports: expose:
- "${APP_PORT:-8000}:80" - "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: healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost/up"] test: ["CMD", "curl", "-fsS", "http://localhost:8080/up"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
start_period: 40s start_period: 40s
@@ -67,6 +162,7 @@ services:
worker: worker:
<<: *app <<: *app
command: ["php", "artisan", "queue:work", "--tries=3", "--max-time=3600", "--sleep=3"] 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 # 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 # migrates, and a worker that starts against an empty schema spends its
@@ -74,7 +170,7 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
app: landing-app:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "php", "artisan", "queue:monitor", "default"] test: ["CMD", "php", "artisan", "queue:monitor", "default"]
@@ -89,6 +185,7 @@ services:
scheduler: scheduler:
<<: *app <<: *app
command: ["php", "artisan", "schedule:work"] command: ["php", "artisan", "schedule:work"]
environment: *app-env
# The FrankenPHP base image ships a healthcheck that probes Caddy's admin # The FrankenPHP base image ships a healthcheck that probes Caddy's admin
# API, and this container does not serve HTTP — inherited, it could never # API, and this container does not serve HTTP — inherited, it could never
@@ -109,17 +206,56 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
app: landing-app:
condition: service_healthy condition: service_healthy
stop_grace_period: 30s 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: postgres:
image: postgres:17-alpine image: postgres:17-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: ${DB_DATABASE:-pireon} POSTGRES_DB: ${DB_DATABASE:-pireon}
POSTGRES_USER: ${DB_USERNAME:-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 # Content is written by a handful of people and read constantly; the
# default collation rules are what the JSON slug lookups sort under. # default collation rules are what the JSON slug lookups sort under.
@@ -141,3 +277,7 @@ services:
volumes: volumes:
pgdata: pgdata:
storage: storage:
networks:
dokploy-network:
external: true

View File

@@ -21,6 +21,21 @@
@media path /storage/* @media path /storage/*
header @media Cache-Control "public, max-age=31536000, immutable" header @media Cache-Control "public, max-age=31536000, immutable"
# Uploaded SVGs are sanitized on the way in (MediaController). This is the
# second line: opened directly rather than through an <img>, an SVG is a
# document, and nothing stored here has any business running script or
# loading anything from elsewhere.
#
# Two matchers, which Caddy ANDs, rather than `path /storage/*.svg`: a `*`
# in the middle of a path does not cross a `/`, so that pattern only ever
# matched files directly under /storage — and every upload is a directory
# below it.
@svg {
path /storage/*
path_regexp (?i)\.svg$
}
header @svg Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:"
# The frontend is a separate application on another origin. # The frontend is a separate application on another origin.
@api path /api/* @api path /api/*
header @api { header @api {

View File

@@ -30,11 +30,6 @@ return [
], ],
], ],
'environment' => [
'production' => 'Producció — això és real',
'other' => 'Entorn de :name',
],
'concurrency' => [ 'concurrency' => [
'title' => 'No s\'ha desat', '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.', '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.',

View File

@@ -28,4 +28,21 @@ return [
'pages' => [ 'pages' => [
'cannot_publish' => 'No tens permís per publicar pàgines.', 'cannot_publish' => 'No tens permís per publicar pàgines.',
], ],
'publication' => [
'forbidden' => 'No tens permís per publicar ni despublicar això.',
],
'media' => [
'not_an_image' => 'El fitxer no és una imatge JPEG, PNG, WebP, GIF, AVIF o SVG.',
'remote' => [
'https_only' => 'La URL de la imatge ha de començar per https://.',
'unresolvable' => 'No s\'ha pogut resoldre el domini de la URL.',
'private' => 'La URL apunta a una adreça privada o reservada: només es descarreguen imatges d\'adreces públiques.',
'unreachable' => 'No s\'ha pogut connectar amb la URL.',
'redirect' => 'La URL redirigeix a una altra adreça. Fes servir l\'adreça final de la imatge.',
'status' => 'La URL ha respost :status en lloc de la imatge.',
'too_large' => 'La imatge supera el màxim de :max KB.',
],
],
]; ];

View File

@@ -30,11 +30,6 @@ return [
], ],
], ],
'environment' => [
'production' => 'Production — this is real',
'other' => ':name environment',
],
'concurrency' => [ 'concurrency' => [
'title' => 'Not saved', '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.', 'body' => 'Somebody else changed this while you were editing it. Reload the page to see the current version — saving now would overwrite that change.',

View File

@@ -28,4 +28,21 @@ return [
'pages' => [ 'pages' => [
'cannot_publish' => 'You do not have permission to publish pages.', 'cannot_publish' => 'You do not have permission to publish pages.',
], ],
'publication' => [
'forbidden' => 'You do not have permission to publish or unpublish this.',
],
'media' => [
'not_an_image' => 'The file is not a JPEG, PNG, WebP, GIF, AVIF or SVG image.',
'remote' => [
'https_only' => 'The image URL must start with https://.',
'unresolvable' => 'The URL\'s domain could not be resolved.',
'private' => 'The URL points at a private or reserved address: images are only downloaded from public ones.',
'unreachable' => 'Could not connect to the URL.',
'redirect' => 'The URL redirects elsewhere. Use the image\'s final address.',
'status' => 'The URL answered :status instead of the image.',
'too_large' => 'The image is larger than the :max KB limit.',
],
],
]; ];

View File

@@ -30,11 +30,6 @@ return [
], ],
], ],
'environment' => [
'production' => 'Producción — esto es real',
'other' => 'Entorno de :name',
],
'concurrency' => [ 'concurrency' => [
'title' => 'No se ha guardado', '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.', '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.',

View File

@@ -28,4 +28,21 @@ return [
'pages' => [ 'pages' => [
'cannot_publish' => 'No tienes permiso para publicar páginas.', 'cannot_publish' => 'No tienes permiso para publicar páginas.',
], ],
'publication' => [
'forbidden' => 'No tienes permiso para publicar ni despublicar esto.',
],
'media' => [
'not_an_image' => 'El fichero no es una imagen JPEG, PNG, WebP, GIF, AVIF o SVG.',
'remote' => [
'https_only' => 'La URL de la imagen tiene que empezar por https://.',
'unresolvable' => 'No se ha podido resolver el dominio de la URL.',
'private' => 'La URL apunta a una dirección privada o reservada: solo se descargan imágenes de direcciones públicas.',
'unreachable' => 'No se ha podido conectar con la URL.',
'redirect' => 'La URL redirige a otra dirección. Usa la dirección final de la imagen.',
'status' => 'La URL ha respondido :status en lugar de la imagen.',
'too_large' => 'La imagen supera el máximo de :max KB.',
],
],
]; ];

4
mcp/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
test
Dockerfile
.dockerignore

26
mcp/Dockerfile Normal file
View 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

File diff suppressed because it is too large Load Diff

20
mcp/package.json Normal file
View 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"
}
}

96
mcp/server.mjs Normal file
View File

@@ -0,0 +1,96 @@
#!/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,
// Over stdio the disk the server reads is the caller's own.
localFiles: !http,
});
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)'}`);
}

80
mcp/src/catalogue.mjs Normal file
View File

@@ -0,0 +1,80 @@
/**
* 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.
* @param {boolean} [options.localFiles] Offer uploads from a local path; see `buildTools`.
*/
constructor({ source, baseUrl, ttlMs = Infinity, localFiles = false }) {
this.source = source;
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.ttlMs = ttlMs;
this.localFiles = localFiles;
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, { localFiles: this.localFiles }),
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();
}
}

234
mcp/src/client.mjs Normal file
View File

@@ -0,0 +1,234 @@
/**
* 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.
*/
import { readFile, stat } from 'node:fs/promises';
import { basename } from 'node:path';
// 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;
// Above the API's own 20 MB so its answer, not this one, is what a caller
// usually sees; this only stops something absurd being read into memory.
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
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 = await 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;
}
async 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;
const uploads = (operation.files ?? []).filter((field) => args[`${field}_path`]);
if (uploads.length > 0) {
// No Content-Type: fetch writes it, boundary included, from the form.
body = new FormData();
for (const [key, value] of Object.entries(args.body ?? {})) {
body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
}
for (const field of uploads) {
body.append(field, ...(await this.readUpload(args[`${field}_path`])));
}
} else 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,
};
}
/** @returns {Promise<[Blob, string]>} */
async readUpload(path) {
let info;
try {
info = await stat(path);
} catch {
throw new Error(`Cannot read ${path}: no such file`);
}
if (!info.isFile()) throw new Error(`${path} is not a file`);
if (info.size > MAX_UPLOAD_BYTES) throw new Error(`${path} is ${Math.round(info.size / 1048576)} MB; the limit is 20 MB`);
return [new Blob([await readFile(path)]), basename(path)];
}
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
View 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
View 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;
}

349
mcp/src/tools.mjs Normal file
View File

@@ -0,0 +1,349 @@
/**
* 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 {string[]} files Multipart fields filled from a local file, read from `<name>_path`.
* @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.
* @param {object} [options]
* @param {boolean} [options.localFiles] Offer uploads from a path on this
* machine. Only true over stdio, where this machine is the caller's own; a
* deployed server would be reading its own disk on a stranger's say-so.
* @returns {Tool[]}
*/
export function buildTools(spec, { localFiles = false } = {}) {
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;
const tool = buildTool(spec, paths, path, method, item, op, localFiles);
if (tool) tools.push(tool);
}
}
// 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, localFiles) {
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;
const multipartSchema = op.requestBody?.content?.['multipart/form-data']?.schema;
// A file in a multipart body cannot travel as JSON — as base64 an ordinary
// photo costs hundreds of thousands of tokens — so the model names a path
// and the server reads it. Only where that path is on the caller's machine.
const files = localFiles && multipartSchema
? Object.entries(toJsonSchema(spec, multipartSchema).properties ?? {})
.filter(([, property]) => property.format === 'binary')
.map(([name]) => name)
: [];
// Multipart only, and no way to send a file from here: nothing to offer.
if (!bodySchema && multipartSchema && files.length === 0) return null;
for (const field of files) {
const name = `${field}_path`;
if (name in properties) {
throw new Error(`${method.toUpperCase()} ${path} has a parameter named "${name}", which the file upload needs`);
}
properties[name] = {
type: 'string',
description: `Absolute path to a file on this machine, uploaded as \`${field}\`. Read by the MCP server; use instead of \`body\`.`,
};
}
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;
// Beside a file, the body carries the upload's other fields (`for`, say)
// rather than the JSON alternative, whose own requirements — a `url` —
// would then be wrong.
if (files.length > 0) delete body.required;
properties.body = {
...body,
description: files.length > 0
? 'JSON request body; with a file, its other fields go here too.'
: 'JSON request body.',
};
// With a file to send instead, the JSON body is the other way in rather
// than a requirement; the API says which one it missed if neither comes.
if (op.requestBody.required && files.length === 0) 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),
files,
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' }] };
}

198
mcp/test/client.test.mjs Normal file
View File

@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
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"/);
});
const uploadMedia = {
method: 'POST',
path: '/v1/media',
params: [],
hasBody: true,
files: ['file'],
requiresAuth: true,
};
test('uploads a local file as multipart, never as JSON', async () => {
const dir = await mkdtemp(join(tmpdir(), 'landing-mcp-'));
const file = join(dir, 'hero.png');
await writeFile(file, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const { fetch, calls } = fakeFetch(() => ({ status: 201, body: { data: { path: 'pages/x.png' } } }));
const client = new LandingClient({ apiBase: 'http://app.test/api', token: 't', fetch });
const response = await client.call(uploadMedia, { file_path: file });
const sent = calls[0].body;
assert.equal(response.status, 201);
assert.ok(sent instanceof FormData);
assert.equal(sent.get('file').name, 'hero.png');
assert.equal(sent.get('file').size, 4);
// fetch has to write it, boundary and all.
assert.equal(calls[0].headers['Content-Type'], undefined);
});
test('sends the JSON body when there is no file', async () => {
const { fetch, calls } = fakeFetch(() => ({ status: 201, body: {} }));
const client = new LandingClient({ apiBase: 'http://app.test/api', token: 't', fetch });
await client.call(uploadMedia, { body: { url: 'https://images.example.org/a.png' } });
assert.equal(calls[0].headers['Content-Type'], 'application/json');
assert.equal(JSON.parse(calls[0].body).url, 'https://images.example.org/a.png');
});
test('says which file it could not read', async () => {
const client = new LandingClient({ apiBase: 'http://app.test/api', token: 't', fetch: async () => assert.fail('no request') });
await assert.rejects(client.call(uploadMedia, { file_path: '/nope/missing.png' }), /\/nope\/missing\.png/);
});

155
mcp/test/http.test.mjs Normal file
View 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);
});

105
mcp/test/tools.test.mjs Normal file
View File

@@ -0,0 +1,105 @@
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',
admin_technologies_index: 'GET /v1/technologies',
admin_technologies_store: 'POST /v1/technologies',
admin_technologies_update: 'PATCH /v1/technologies/{technology}',
admin_media_store: 'POST /v1/media',
};
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);
}
});
test('over stdio an upload takes a local path, and the JSON body becomes optional', () => {
const local = buildTools(spec, { localFiles: true }).find((tool) => tool.name === 'admin_media_store');
assert.deepEqual(local.operation.files, ['file']);
assert.equal(local.inputSchema.properties.file_path.type, 'string');
assert.ok(local.inputSchema.properties.body.properties.url);
assert.ok(local.inputSchema.properties.body.properties.for);
assert.equal(local.inputSchema.required, undefined);
// `url` is the JSON alternative; beside a file it must not be demanded.
assert.equal(local.inputSchema.properties.body.required, undefined);
});
test('over HTTP there is no local path to offer: only the URL', () => {
const remote = byName.get('admin_media_store');
assert.deepEqual(remote.operation.files, []);
assert.ok(!('file_path' in remote.inputSchema.properties));
assert.deepEqual(remote.inputSchema.required, ['body']);
});

View File

@@ -18,6 +18,10 @@
</include> </include>
</source> </source>
<php> <php>
<!-- The suite, not any one test, outgrew PHP's usual 128M: the panel
tests boot Filament again and again in one process. Stated here so
it runs the same on every machine, whatever its php.ini says. -->
<ini name="memory_limit" value="512M"/>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>

View File

@@ -106,8 +106,18 @@ tags:
description: Translatable page content description: Translatable page content
- name: Categories - name: Categories
description: Translatable page categories description: Translatable page categories
- name: Projects
description: The collective's work — translatable, with its team and stack
- name: Members
description: The people in the collective
- name: Services
description: What the collective offers
- name: Technologies
description: What the collective uses, and its place on the tech radar
- name: Users - name: Users
description: Local user administration description: Local user administration
- name: Media
description: Images uploaded for a record to point at
- name: Roles - name: Roles
description: Local authorisation model (read-only) description: Local authorisation model (read-only)
@@ -909,8 +919,9 @@ paths:
description: | description: |
Proxies the identity server's `password` and `refresh_token` grants so Proxies the identity server's `password` and `refresh_token` grants so
first-party clients need not embed the OAuth server's URL or secret. first-party clients need not embed the OAuth server's URL or secret.
Rate limited to 6 requests per minute. Browser clients should use Rate limited to 6 requests per minute per account and address, and 30
Authorization Code + PKCE instead. per address overall. Browser clients should use Authorization Code +
PKCE instead.
security: [] security: []
requestBody: requestBody:
required: true required: true
@@ -1171,6 +1182,533 @@ paths:
"204": { description: Deleted } "204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" } "403": { $ref: "#/components/responses/Forbidden" }
/v1/technologies:
get:
tags: [Technologies]
summary: List technologies
description: Ordered by `position`, then name.
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: category
in: query
schema: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] }
- name: radar_ring
in: query
description: '`none` for the technologies kept off the radar.'
schema: { type: string, enum: [adopt, trial, assess, hold, none] }
- name: search
in: query
description: Matches the name.
schema: { type: string }
responses:
"200":
description: Paginated technologies
content:
application/json:
schema: { $ref: "#/components/schemas/TechnologyCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Technologies]
summary: Create a technology
description: |
For a logo, upload it first with `POST /v1/media` and `for:
technologies`, and send the `path` it answers with as `logo`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/TechnologyWrite" }
responses:
"201":
description: Technology created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Technology" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/technologies/{technology}:
parameters:
- name: technology
in: path
required: true
schema: { type: integer }
get:
tags: [Technologies]
summary: Show a technology
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The technology
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Technology" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Technologies]
summary: Update a technology
description: |
Partial update. `description` and `radar_note` merge into the existing
translations; send `radar_ring: null` to take it off the radar.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/TechnologyWrite" }
responses:
"200":
description: Technology updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Technology" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Technologies]
summary: Delete a technology
description: Refused with 403 while a project, member or call still uses it.
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/projects:
get:
tags: [Projects]
summary: List projects
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: status
in: query
schema: { type: string, enum: [idea, active, paused, completed, archived] }
- name: search
in: query
description: Matches the title within the active language only.
schema: { type: string }
- name: trashed
in: query
schema: { type: boolean }
responses:
"200":
description: Paginated projects
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Projects]
summary: Create a project
description: |
Relations are sent as ids and replaced whole when present:
`technology_ids`, `service_ids`, and `members` with each one's role.
`published_at` and `unpublish_at` require `projects.publish`. For a
cover, upload it with `POST /v1/media` and `for: projects`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectWrite" }
responses:
"201":
description: Project created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/projects/{project}:
parameters:
- name: project
in: path
required: true
schema: { type: integer }
get:
tags: [Projects]
summary: Show a project
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The project
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Projects]
summary: Update a project
description: Partial update. Translatable fields merge into the existing map.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ProjectWrite" }
responses:
"200":
description: Project updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Projects]
summary: Soft-delete a project
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/projects/{project}/restore:
post:
tags: [Projects]
summary: Restore a soft-deleted project
parameters:
- name: project
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Project restored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/members:
get:
tags: [Members]
summary: List members
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: search
in: query
description: Matches the name.
schema: { type: string }
- name: is_public
in: query
schema: { type: boolean }
- name: trashed
in: query
schema: { type: boolean }
responses:
"200":
description: Paginated members
content:
application/json:
schema: { $ref: "#/components/schemas/MemberCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Members]
summary: Create a member
description: |
`links` is a label → URL map. `technology_ids` is replaced whole when
present; which projects someone is on is set from the project, where
the role belongs. For a photo, upload it with `POST /v1/media` and
`for: members`. Someone may edit their own profile without
`members.update`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/MemberWrite" }
responses:
"201":
description: Member created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Member" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/members/{member}:
parameters:
- name: member
in: path
required: true
schema: { type: integer }
get:
tags: [Members]
summary: Show a member
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The member
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Member" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Members]
summary: Update a member
description: Partial update. Translatable fields merge into the existing map.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/MemberWrite" }
responses:
"200":
description: Member updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Member" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Members]
summary: Soft-delete a member
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/members/{member}/restore:
post:
tags: [Members]
summary: Restore a soft-deleted member
parameters:
- name: member
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Member restored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Member" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/services:
get:
tags: [Services]
summary: List services
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
- $ref: "#/components/parameters/PerPage"
- name: is_published
in: query
schema: { type: boolean }
- name: search
in: query
description: Matches the title within the active language only.
schema: { type: string }
- name: trashed
in: query
schema: { type: boolean }
responses:
"200":
description: Paginated services
content:
application/json:
schema: { $ref: "#/components/schemas/ServiceCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Services]
summary: Create a service
description: |
`technology_ids` and `project_ids` are replaced whole when present.
`is_published`, `published_at` and `unpublish_at` require
`services.publish`.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ServiceWrite" }
responses:
"201":
description: Service created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Service" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/services/{service}:
parameters:
- name: service
in: path
required: true
schema: { type: integer }
get:
tags: [Services]
summary: Show a service
parameters:
- $ref: "#/components/parameters/Lang"
- $ref: "#/components/parameters/With"
responses:
"200":
description: The service
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Service" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Services]
summary: Update a service
description: Partial update. Translatable fields merge into the existing map.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ServiceWrite" }
responses:
"200":
description: Service updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Service" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Services]
summary: Soft-delete a service
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/services/{service}/restore:
post:
tags: [Services]
summary: Restore a soft-deleted service
parameters:
- name: service
in: path
required: true
schema: { type: integer }
responses:
"200":
description: Service restored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Service" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/media:
post:
tags: [Media]
summary: Upload an image
description: |
Stores an image and answers with the `path` a record points at — send
it as a page's `featured_image`. Two ways in, exactly one per request:
- **`multipart/form-data`** with the image as `file`.
- **`application/json`** with a `url`, which the API downloads itself.
HTTPS only, from a public address — anything resolving to a private,
reserved or loopback address is refused — and redirects are not
followed: send the image's final address.
JPEG, PNG, WebP, GIF, AVIF or SVG, decided from the file's bytes rather
than its name or declared type. An SVG is stored sanitized: scripts,
event handlers and references to anything outside the file are
removed. 20 MB at most.
`for` says which kind of record the image is for — `pages` (the
default) or `technologies` — which decides where it is stored and whose
permission it takes.
The smaller versions and the placeholder are derived afterwards, on the
queue: `processed` says whether that has happened, and the image serves
either way. Requires permission to create or edit the records named in
`for`.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: The image.
for: { $ref: "#/components/schemas/MediaFor" }
application/json:
schema:
type: object
required: [url]
properties:
url:
type: string
format: uri
description: An `https://` address the API downloads the image from.
example: https://images.example.org/hero.jpg
for: { $ref: "#/components/schemas/MediaFor" }
responses:
"201":
description: Image stored
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Media" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/users: /v1/users:
get: get:
tags: [Users] tags: [Users]
@@ -2123,6 +2661,305 @@ components:
nullable: true nullable: true
description: Must be after `published_at`. When it arrives the record leaves the site on its own. description: Must be after `published_at`. When it arrives the record leaves the site on its own.
Project:
type: object
properties:
id: { type: integer }
locale: { type: string, description: The language the fields below were resolved into. }
title: { type: string, nullable: true }
slug: { type: string, nullable: true }
tagline: { type: string, nullable: true }
description: { type: string, nullable: true, description: HTML from the panel's rich editor. }
decisions: { type: string, nullable: true, description: HTML. Why the technical choices were made. }
postmortem: { type: string, nullable: true, description: HTML. What went wrong and what was learned. }
meta_title: { type: string, nullable: true }
meta_description: { type: string, nullable: true }
cover_image_alt: { type: string, nullable: true }
status: { type: string, enum: [idea, active, paused, completed, archived] }
is_featured: { type: boolean }
needs_help: { type: boolean }
demo_url: { type: string, nullable: true, format: uri }
cover_image: { type: string, nullable: true, description: The stored path — what a client PATCHes back. }
cover_image_media:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
readOnly: true
started_at: { type: string, format: date, nullable: true }
ended_at: { type: string, format: date, nullable: true }
published_at: { type: string, format: date-time, nullable: true, description: On the site from then on; null keeps it off. }
unpublish_at: { type: string, format: date-time, nullable: true }
position: { type: integer }
client_id: { type: integer, nullable: true }
lead_member_id: { type: integer, nullable: true }
technology_ids: { type: array, items: { type: integer } }
service_ids: { type: array, items: { type: integer } }
members:
type: array
items:
type: object
properties:
id: { type: integer }
name: { type: string }
role: { type: string, nullable: true }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
ProjectWrite:
type: object
required: [title, slug]
properties:
title: { $ref: "#/components/schemas/TranslatedField" }
slug:
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
description: Unique per language.
tagline: { $ref: "#/components/schemas/TranslatedField" }
description: { $ref: "#/components/schemas/TranslatedField" }
decisions: { $ref: "#/components/schemas/TranslatedField" }
postmortem: { $ref: "#/components/schemas/TranslatedField" }
meta_title: { $ref: "#/components/schemas/TranslatedField" }
meta_description: { $ref: "#/components/schemas/TranslatedField" }
cover_image_alt: { $ref: "#/components/schemas/TranslatedField" }
status: { type: string, enum: [idea, active, paused, completed, archived] }
is_featured: { type: boolean }
needs_help: { type: boolean }
demo_url: { type: string, format: uri, nullable: true }
cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. }
started_at: { type: string, format: date, nullable: true }
ended_at: { type: string, format: date, nullable: true }
published_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. }
unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `projects.publish`. }
position: { type: integer, minimum: 0 }
client_id: { type: integer, nullable: true }
lead_member_id: { type: integer, nullable: true }
technology_ids: { type: array, items: { type: integer } }
service_ids: { type: array, items: { type: integer } }
members:
type: array
description: The whole team, replacing the previous one.
items:
type: object
required: [id]
properties:
id: { type: integer }
role: { type: string, nullable: true, maxLength: 120 }
ProjectCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Project" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Member:
type: object
properties:
id: { type: integer }
locale: { type: string }
name: { type: string }
slug: { type: string }
headline: { type: string, nullable: true }
bio: { type: string, nullable: true, description: Plain text. }
avatar: { type: string, nullable: true, description: The stored path. }
avatar_media:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
readOnly: true
email: { type: string, nullable: true, description: Never shown on the site. }
github_username: { type: string, nullable: true }
links: { type: object, additionalProperties: { type: string }, description: Label → URL. }
is_public: { type: boolean }
is_founder: { type: boolean }
joined_at: { type: string, format: date, nullable: true }
left_at: { type: string, format: date, nullable: true }
position: { type: integer }
technology_ids: { type: array, items: { type: integer } }
projects:
type: array
items:
type: object
properties:
id: { type: integer }
title: { type: string, nullable: true }
role: { type: string, nullable: true }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
MemberWrite:
type: object
required: [name, slug]
properties:
name: { type: string, maxLength: 255 }
slug: { type: string, maxLength: 255, pattern: "^[A-Za-z0-9_-]+$" }
headline: { $ref: "#/components/schemas/TranslatedField" }
bio: { $ref: "#/components/schemas/TranslatedField" }
avatar: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=members`. }
email: { type: string, format: email, nullable: true }
github_username: { type: string, nullable: true, maxLength: 120 }
links: { type: object, nullable: true, additionalProperties: { type: string, format: uri } }
is_public: { type: boolean }
is_founder: { type: boolean }
joined_at: { type: string, format: date, nullable: true }
left_at: { type: string, format: date, nullable: true }
position: { type: integer, minimum: 0 }
technology_ids: { type: array, items: { type: integer } }
MemberCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Member" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Service:
type: object
properties:
id: { type: integer }
locale: { type: string }
title: { type: string, nullable: true }
slug: { type: string, nullable: true }
tagline: { type: string, nullable: true }
description: { type: string, nullable: true, description: Plain text. }
meta_title: { type: string, nullable: true }
meta_description: { type: string, nullable: true }
icon: { type: string, nullable: true }
is_featured: { type: boolean }
is_published: { type: boolean }
published_at: { type: string, format: date-time, nullable: true }
unpublish_at: { type: string, format: date-time, nullable: true }
position: { type: integer }
technology_ids: { type: array, items: { type: integer } }
project_ids: { type: array, items: { type: integer } }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at: { type: string, format: date-time, nullable: true }
translations: { type: object, nullable: true, description: "Every language, with `?with=translations`." }
ServiceWrite:
type: object
required: [title, slug]
properties:
title: { $ref: "#/components/schemas/TranslatedField" }
slug:
allOf: [{ $ref: "#/components/schemas/TranslatedField" }]
description: Unique per language.
tagline: { $ref: "#/components/schemas/TranslatedField" }
description: { $ref: "#/components/schemas/TranslatedField" }
meta_title: { $ref: "#/components/schemas/TranslatedField" }
meta_description: { $ref: "#/components/schemas/TranslatedField" }
icon: { type: string, nullable: true, maxLength: 120, description: "An icon name, e.g. `support`." }
is_featured: { type: boolean }
position: { type: integer, minimum: 0 }
is_published: { type: boolean, description: Requires `services.publish`. }
published_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. }
unpublish_at: { type: string, format: date-time, nullable: true, description: Requires `services.publish`. }
technology_ids: { type: array, items: { type: integer } }
project_ids: { type: array, items: { type: integer } }
ServiceCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Service" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
MediaFor:
type: string
enum: [pages, technologies, projects, members]
default: pages
description: The kind of record the image is for.
Technology:
type: object
properties:
id: { type: integer }
locale: { type: string, description: The language the prose below was resolved into. }
name: { type: string, description: A proper noun — the same in every language. }
slug: { type: string }
description: { type: string, nullable: true }
radar_note: { type: string, nullable: true, description: Why it sits in its ring. }
category: { type: string, enum: [language, framework, datastore, infrastructure, tool, practice] }
radar_ring:
type: string
enum: [adopt, trial, assess, hold]
nullable: true
description: Null keeps it off the radar; projects can still use it.
color: { type: string, nullable: true, example: "#DD0031" }
logo:
type: string
nullable: true
description: The stored path — what `POST /v1/media` answered — and what a client PATCHes back.
logo_media:
allOf: [{ $ref: "#/components/schemas/Image" }]
nullable: true
readOnly: true
website: { type: string, nullable: true, format: uri }
position: { type: integer }
projects_count: { type: integer }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
translations:
type: object
nullable: true
description: Every language of `description` and `radar_note`, with `?with=translations`.
TechnologyWrite:
type: object
required: [name, slug]
properties:
name: { type: string, maxLength: 120 }
slug: { type: string, maxLength: 120, pattern: "^[A-Za-z0-9_-]+$" }
description: { $ref: "#/components/schemas/TranslatedField" }
radar_note: { $ref: "#/components/schemas/TranslatedField" }
category:
type: string
enum: [language, framework, datastore, infrastructure, tool, practice]
default: tool
radar_ring: { type: string, enum: [adopt, trial, assess, hold], nullable: true }
color: { type: string, nullable: true, pattern: "^#[0-9a-fA-F]{6}$" }
logo:
type: string
nullable: true
description: A path from `POST /v1/media`, uploaded with `for=technologies`.
website: { type: string, format: uri, nullable: true, maxLength: 255 }
position: { type: integer, minimum: 0 }
TechnologyCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Technology" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Media:
type: object
properties:
path:
type: string
description: What a record stores, e.g. as a page's `featured_image`.
example: pages/Xk3fQ9sLw2PmN8vR1tY6uZ0aB4cD7eF5gH2jK9mN.jpg
url: { type: string, format: uri, description: Absolute. }
mime: { type: string, nullable: true, example: image/jpeg }
size: { type: integer, nullable: true, description: Bytes. }
width: { type: integer, nullable: true }
height: { type: integer, nullable: true }
processed:
type: boolean
description: |
Whether the queue has derived the smaller versions and the
placeholder yet. The image serves either way.
Category: Category:
type: object type: object
properties: properties:

View File

@@ -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

View File

@@ -12,8 +12,13 @@ use App\Http\Controllers\Api\PublicApi\V1\SiteController;
use App\Http\Controllers\Api\PublicApi\V1\TelemetryController; use App\Http\Controllers\Api\PublicApi\V1\TelemetryController;
use App\Http\Controllers\Api\V1\CategoryController; use App\Http\Controllers\Api\V1\CategoryController;
use App\Http\Controllers\Api\V1\MeController; use App\Http\Controllers\Api\V1\MeController;
use App\Http\Controllers\Api\V1\MediaController;
use App\Http\Controllers\Api\V1\MemberController;
use App\Http\Controllers\Api\V1\PageController; use App\Http\Controllers\Api\V1\PageController;
use App\Http\Controllers\Api\V1\ProjectController;
use App\Http\Controllers\Api\V1\RoleController; use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\ServiceController;
use App\Http\Controllers\Api\V1\TechnologyController;
use App\Http\Controllers\Api\V1\TokenController; use App\Http\Controllers\Api\V1\TokenController;
use App\Http\Controllers\Api\V1\UserController; use App\Http\Controllers\Api\V1\UserController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -152,7 +157,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
// Convenience endpoint for first-party clients: exchanges credentials for // Convenience endpoint for first-party clients: exchanges credentials for
// a token upstream so they do not need to know the identity server's URL. // a token upstream so they do not need to know the identity server's URL.
Route::post('auth/token', [TokenController::class, 'store']) Route::post('auth/token', [TokenController::class, 'store'])
->middleware('throttle:6,1') ->middleware('throttle:auth-token')
->name('auth.token'); ->name('auth.token');
Route::middleware(['auth:api', 'throttle:api'])->group(function () { Route::middleware(['auth:api', 'throttle:api'])->group(function () {
@@ -165,6 +170,26 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::apiResource('categories', CategoryController::class); Route::apiResource('categories', CategoryController::class);
Route::apiResource('technologies', TechnologyController::class);
// The collective's work, people and offer. Soft-deleted, so each can
// be brought back as a page can.
foreach ([
'projects' => ProjectController::class,
'members' => MemberController::class,
'services' => ServiceController::class,
] as $resource => $controller) {
$parameter = str($resource)->singular()->toString();
Route::apiResource($resource, $controller);
Route::post("{$resource}/{{$parameter}}/restore", [$controller, 'restore'])
->withTrashed()
->name("{$resource}.restore");
}
// Upload only: the file is then referenced from a record by its path.
Route::post('media', [MediaController::class, 'store'])->name('media.store');
Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']); Route::apiResource('users', UserController::class)->only(['index', 'show', 'update']);
// Declared before the resource route so "catalogue" is not swallowed // Declared before the resource route so "catalogue" is not swallowed

View File

@@ -5,10 +5,16 @@ declare(strict_types=1);
namespace Tests\Feature\Api; namespace Tests\Feature\Api;
use App\Models\Category; use App\Models\Category;
use App\Models\Member;
use App\Models\Page; use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\Technology;
use App\Models\User; use App\Models\User;
use Database\Seeders\RolePermissionSeeder; use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon; use Tests\FakesPireon;
use Tests\TestCase; use Tests\TestCase;
@@ -62,6 +68,10 @@ class AdminApiContractTest extends TestCase
'pages' => ['path' => '/v1/pages'], 'pages' => ['path' => '/v1/pages'],
'categories' => ['path' => '/v1/categories'], 'categories' => ['path' => '/v1/categories'],
'users' => ['path' => '/v1/users'], 'users' => ['path' => '/v1/users'],
'technologies' => ['path' => '/v1/technologies'],
'projects' => ['path' => '/v1/projects'],
'members' => ['path' => '/v1/members'],
'services' => ['path' => '/v1/services'],
]; ];
} }
@@ -70,6 +80,10 @@ class AdminApiContractTest extends TestCase
{ {
$page = Page::factory()->create(); $page = Page::factory()->create();
$category = Category::factory()->create(); $category = Category::factory()->create();
$technology = Technology::factory()->create();
$project = Project::factory()->create();
$member = Member::factory()->create();
$service = Service::factory()->create();
// One request, one documented path — the URL a real caller hits and // One request, one documented path — the URL a real caller hits and
// the path key that describes it, side by side rather than derived // the path key that describes it, side by side rather than derived
@@ -80,6 +94,14 @@ class AdminApiContractTest extends TestCase
'page' => ["/v1/pages/{$page->id}", '/v1/pages/{page}'], 'page' => ["/v1/pages/{$page->id}", '/v1/pages/{page}'],
'categories' => ['/v1/categories', '/v1/categories'], 'categories' => ['/v1/categories', '/v1/categories'],
'category' => ["/v1/categories/{$category->id}", '/v1/categories/{category}'], 'category' => ["/v1/categories/{$category->id}", '/v1/categories/{category}'],
'technologies' => ['/v1/technologies', '/v1/technologies'],
'technology' => ["/v1/technologies/{$technology->id}", '/v1/technologies/{technology}'],
'projects' => ['/v1/projects', '/v1/projects'],
'project' => ["/v1/projects/{$project->id}", '/v1/projects/{project}'],
'members' => ['/v1/members', '/v1/members'],
'member' => ["/v1/members/{$member->id}", '/v1/members/{member}'],
'services' => ['/v1/services', '/v1/services'],
'service' => ["/v1/services/{$service->id}", '/v1/services/{service}'],
'users' => ['/v1/users', '/v1/users'], 'users' => ['/v1/users', '/v1/users'],
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'], 'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
'me' => ['/v1/me', '/v1/me'], 'me' => ['/v1/me', '/v1/me'],
@@ -120,6 +142,22 @@ class AdminApiContractTest extends TestCase
$this->assertSame([], $problems, "The admin API and its specification disagree:\n".implode("\n", $problems)); $this->assertSame([], $problems, "The admin API and its specification disagree:\n".implode("\n", $problems));
} }
#[Test]
public function an_upload_answers_in_the_shape_it_documents(): void
{
Storage::fake((string) config('media.disk'));
$response = $this->post('/api/v1/media', [
'file' => UploadedFile::fake()->image('hero.jpg', 64, 48),
], ['Accept' => 'application/json'])->assertCreated();
$record = $response->json('data');
$schema = $this->recordSchema('/v1/media', 'post', 201);
$this->assertSame([], array_values(array_diff(array_keys($record), $this->documentedKeys($schema))), 'Sent but not documented');
$this->assertSame([], array_values(array_diff($this->alwaysPresent($schema), array_keys($record))), 'Documented but not sent');
}
/** /**
* The pagination envelope every listing here uses — `data`, `links`, * The pagination envelope every listing here uses — `data`, `links`,
* `meta` — checked the same way the record inside it is: nothing sent * `meta` — checked the same way the record inside it is: nothing sent

View File

@@ -0,0 +1,257 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\ProjectStatus;
use App\Models\Client;
use App\Models\Member;
use App\Models\Project;
use App\Models\Service;
use App\Models\Technology;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Projects, members and services through the admin API: what the MCP server
* uses to load the collective's work without anyone retyping it into forms.
*/
class CollectiveApiTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
protected function actingWithRole(string $role): User
{
$user = User::factory()->create();
$user->syncRoles([$role]);
$this->withHeaders($this->apiHeadersFor($user));
return $user;
}
#[Test]
public function a_project_arrives_with_its_team_stack_client_and_services(): void
{
$this->actingWithRole('editor');
[$ada, $linus] = Member::factory()->count(2)->create();
$technologies = Technology::factory()->count(2)->create();
$client = Client::factory()->create();
$service = Service::factory()->create();
$response = $this->postJson('/api/v1/projects?lang=es&with=translations', [
'title' => ['es' => 'Catálogo abierto', 'en' => 'Open catalogue'],
'slug' => ['es' => 'catalogo', 'en' => 'catalogue'],
'tagline' => ['es' => 'Una herramienta pequeña.'],
'status' => 'active',
'client_id' => $client->id,
'lead_member_id' => $ada->id,
'technology_ids' => $technologies->pluck('id')->all(),
'service_ids' => [$service->id],
'members' => [['id' => $ada->id, 'role' => 'Responsable'], ['id' => $linus->id]],
])->assertCreated();
$response
->assertJsonPath('data.title', 'Catálogo abierto')
->assertJsonPath('data.translations.title.en', 'Open catalogue')
->assertJsonPath('data.status', 'active')
->assertJsonPath('data.client_id', $client->id)
->assertJsonCount(2, 'data.technology_ids')
->assertJsonPath('data.members.0.role', 'Responsable');
$project = Project::query()->sole();
$this->assertSame('Responsable', $project->members()->find($ada->id)->pivot->role);
$this->assertNull($project->members()->find($linus->id)->pivot->role);
}
#[Test]
public function a_partial_update_leaves_relations_alone_unless_they_are_sent(): void
{
$this->actingWithRole('editor');
$project = Project::factory()->create();
$technology = Technology::factory()->create();
$project->technologies()->attach($technology);
$this->patchJson("/api/v1/projects/{$project->id}", ['status' => 'paused'])
->assertOk()
->assertJsonPath('data.technology_ids', [$technology->id]);
$this->patchJson("/api/v1/projects/{$project->id}", ['technology_ids' => []])
->assertOk()
->assertJsonPath('data.technology_ids', []);
$this->assertSame(ProjectStatus::Paused, $project->fresh()->status);
}
#[Test]
public function writing_a_project_is_not_publishing_it(): void
{
// A writer authors projects but may not put them on the site.
$this->actingWithRole('writer');
$this->postJson('/api/v1/projects', [
'title' => ['es' => 'Borrador'],
'slug' => ['es' => 'borrador'],
'published_at' => now()->toIso8601String(),
])
->assertUnprocessable()
->assertJsonValidationErrors('published_at');
$this->postJson('/api/v1/projects', [
'title' => ['es' => 'Borrador'],
'slug' => ['es' => 'borrador'],
])->assertCreated();
}
#[Test]
public function an_editor_may_publish_a_project(): void
{
$this->actingWithRole('editor');
$this->postJson('/api/v1/projects', [
'title' => ['es' => 'Publicado'],
'slug' => ['es' => 'publicado'],
'published_at' => '2026-09-01T10:00:00Z',
])
->assertCreated()
->assertJsonPath('data.published_at', '2026-09-01T10:00:00+00:00');
}
#[Test]
public function a_deleted_project_can_be_brought_back(): void
{
$this->actingWithRole('editor');
$project = Project::factory()->create();
$this->deleteJson("/api/v1/projects/{$project->id}")->assertNoContent();
$this->assertSoftDeleted($project);
$this->getJson('/api/v1/projects?trashed=1')->assertJsonCount(1, 'data');
$this->postJson("/api/v1/projects/{$project->id}/restore")->assertOk();
$this->assertNotSoftDeleted($project);
}
#[Test]
public function a_member_arrives_with_links_and_technologies(): void
{
$this->actingWithRole('editor');
$technology = Technology::factory()->create();
$this->postJson('/api/v1/members?lang=es', [
'name' => 'Ada Lovelace',
'slug' => 'ada',
'headline' => ['es' => 'Backend', 'en' => 'Backend'],
'github_username' => 'ada',
'links' => ['web' => 'https://ada.example.org'],
'is_founder' => true,
'joined_at' => '2024-09-01',
'technology_ids' => [$technology->id],
])
->assertCreated()
->assertJsonPath('data.headline', 'Backend')
->assertJsonPath('data.links.web', 'https://ada.example.org')
->assertJsonPath('data.joined_at', '2024-09-01')
->assertJsonPath('data.technology_ids', [$technology->id]);
}
#[Test]
public function member_slugs_and_links_are_checked(): void
{
$this->actingWithRole('editor');
Member::factory()->create(['slug' => 'ada']);
$this->postJson('/api/v1/members', ['name' => 'Ada', 'slug' => 'ada'])
->assertUnprocessable()
->assertJsonValidationErrors('slug');
$this->postJson('/api/v1/members', ['name' => 'Bob', 'slug' => 'bob', 'links' => ['web' => 'not a url']])
->assertUnprocessable()
->assertJsonValidationErrors('links.web');
}
#[Test]
public function someone_may_edit_their_own_profile_without_members_update(): void
{
$viewer = $this->actingWithRole('viewer');
$own = Member::factory()->create(['user_id' => $viewer->id]);
$someoneElse = Member::factory()->create();
$this->patchJson("/api/v1/members/{$own->id}", ['github_username' => 'me'])->assertOk();
$this->patchJson("/api/v1/members/{$someoneElse->id}", ['github_username' => 'them'])->assertForbidden();
}
#[Test]
public function a_service_links_to_the_work_behind_it(): void
{
$this->actingWithRole('editor');
$project = Project::factory()->create();
$technology = Technology::factory()->create();
$this->postJson('/api/v1/services?lang=es', [
'title' => ['es' => 'Formación'],
'slug' => ['es' => 'formacion'],
'tagline' => ['es' => 'Talleres prácticos.'],
'icon' => 'support',
'is_published' => true,
'project_ids' => [$project->id],
'technology_ids' => [$technology->id],
])
->assertCreated()
->assertJsonPath('data.title', 'Formación')
->assertJsonPath('data.is_published', true)
->assertJsonPath('data.project_ids', [$project->id]);
}
#[Test]
public function writing_a_service_is_not_publishing_it(): void
{
$this->actingWithRole('writer');
$service = Service::factory()->create(['is_published' => false]);
$this->patchJson("/api/v1/services/{$service->id}", ['is_published' => true])
->assertUnprocessable()
->assertJsonValidationErrors('is_published');
$this->patchJson("/api/v1/services/{$service->id}", ['tagline' => ['es' => 'Nuevo texto']])
->assertOk();
$this->assertFalse($service->fresh()->is_published);
}
#[Test]
public function a_reader_can_list_but_not_write(): void
{
$this->actingWithRole('viewer');
Project::factory()->create();
$this->getJson('/api/v1/projects')->assertOk()->assertJsonCount(1, 'data');
$this->getJson('/api/v1/services')->assertOk();
$this->getJson('/api/v1/members')->assertOk();
$this->postJson('/api/v1/projects', ['title' => ['es' => 'X'], 'slug' => ['es' => 'x']])->assertForbidden();
$this->postJson('/api/v1/services', ['title' => ['es' => 'X'], 'slug' => ['es' => 'x']])->assertForbidden();
}
}

View File

@@ -0,0 +1,409 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Models\Media;
use App\Models\Page;
use App\Models\User;
use App\Services\Media\HostResolver;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
class MediaUploadTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Storage::fake((string) config('media.disk'));
}
protected function actingWithRole(string $role): void
{
$user = User::factory()->create();
$user->syncRoles([$role]);
$this->withHeaders($this->apiHeadersFor($user));
}
/**
* Answers DNS for the hosts the tests invent, so nothing here depends on
* the network — and so a test can make a name resolve somewhere private.
*
* @param array<string, array<int, string>> $answers
*/
protected function resolving(array $answers): void
{
$this->app->instance(HostResolver::class, new class($answers) extends HostResolver
{
/** @param array<string, array<int, string>> $answers */
public function __construct(private array $answers) {}
public function resolve(string $host): array
{
return $this->answers[$host] ?? [];
}
});
}
/**
* `Http::assertNothingSent()` would also count the token check against the
* identity server; what matters is that the image host was never asked.
*/
protected function assertNoImageWasFetched(): void
{
Http::assertNotSent(fn ($request) => ! str_starts_with($request->url(), $this->pireonServer));
}
protected function png(int $width = 12, int $height = 8): string
{
return UploadedFile::fake()->image('x.png', $width, $height)->get();
}
#[Test]
public function it_stores_an_uploaded_image_where_the_panel_puts_a_pages_images(): void
{
$this->actingWithRole('editor');
$response = $this->post('/api/v1/media', [
'file' => UploadedFile::fake()->image('Foto de portada.JPG', 1200, 800),
], ['Accept' => 'application/json']);
$response->assertCreated()
->assertJsonPath('data.mime', 'image/jpeg')
->assertJsonPath('data.width', 1200)
->assertJsonPath('data.height', 800);
$path = $response->json('data.path');
// Named by the server, never after what the client sent.
$this->assertMatchesRegularExpression('#^pages/[A-Za-z0-9]{40}\.jpg$#', $path);
Storage::disk((string) config('media.disk'))->assertExists($path);
$this->assertDatabaseHas('media', ['path' => $path]);
$this->assertStringEndsWith($path, $response->json('data.url'));
}
#[Test]
public function the_path_it_answers_with_is_what_a_page_points_at(): void
{
$this->actingWithRole('editor');
$path = $this->post('/api/v1/media', [
'file' => UploadedFile::fake()->image('hero.png', 800, 600),
], ['Accept' => 'application/json'])->json('data.path');
$page = Page::factory()->create();
$this->patchJson("/api/v1/pages/{$page->id}", ['featured_image' => $path])
->assertOk()
->assertJsonPath('data.featured_image', $path)
->assertJsonPath('data.featured_image_media.width', 800);
// One upload, one row: attaching it to the page reuses what the upload
// already created instead of starting over.
$this->assertSame(1, Media::query()->where('path', $path)->count());
}
#[Test]
public function it_downloads_an_image_from_a_public_url(): void
{
$this->actingWithRole('editor');
$this->resolving(['images.example.test' => ['93.184.216.34']]);
Http::fake([
'https://images.example.test/*' => Http::response($this->png(30, 20), 200, ['Content-Type' => 'image/png']),
]);
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/cover.png'])
->assertCreated()
->assertJsonPath('data.mime', 'image/png')
->assertJsonPath('data.width', 30);
}
#[Test]
public function it_says_who_is_fetching(): void
{
config()->set('app.name', 'Pireon Admin');
config()->set('app.url', 'https://admin.example.org');
$this->actingWithRole('editor');
$this->resolving(['images.example.test' => ['93.184.216.34']]);
Http::fake([
'https://images.example.test/*' => Http::response($this->png(), 200),
]);
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])->assertCreated();
// Hosts such as Wikimedia answer Guzzle's anonymous default with 403.
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://images.example.test/')
&& $request->header('User-Agent') === ['pireon-admin/1.0 (+https://admin.example.org; media fetcher)']);
}
/**
* @return array<string, array{0: array<string, array<int, string>>, 1: string}>
*/
public static function unsafeDestinations(): array
{
return [
'a name resolving to a private address' => [['intranet.example.test' => ['10.0.0.5']], 'https://intranet.example.test/a.png'],
'one private address among public ones' => [['mixed.example.test' => ['93.184.216.34', '192.168.1.10']], 'https://mixed.example.test/a.png'],
'loopback, literally' => [[], 'https://127.0.0.1/a.png'],
'the cloud metadata service' => [[], 'https://169.254.169.254/latest/meta-data'],
'shared address space' => [[], 'https://100.64.0.1/a.png'],
'IPv6 loopback' => [[], 'https://[::1]/a.png'],
'IPv4-mapped loopback' => [[], 'https://[::ffff:127.0.0.1]/a.png'],
'a name that does not resolve' => [[], 'https://nowhere.example.test/a.png'],
];
}
/**
* @param array<string, array<int, string>> $dns
*/
#[Test]
#[DataProvider('unsafeDestinations')]
public function it_never_fetches_from_somewhere_private(array $dns, string $url): void
{
$this->actingWithRole('editor');
$this->resolving($dns);
Http::fake();
$this->postJson('/api/v1/media', ['url' => $url])
->assertUnprocessable()
->assertJsonValidationErrors('url');
$this->assertNoImageWasFetched();
}
#[Test]
public function it_refuses_plain_http(): void
{
$this->actingWithRole('editor');
Http::fake();
$this->postJson('/api/v1/media', ['url' => 'http://images.example.test/a.png'])
->assertUnprocessable()
->assertJsonValidationErrors('url');
$this->assertNoImageWasFetched();
}
#[Test]
public function it_does_not_follow_a_redirect(): void
{
$this->actingWithRole('editor');
$this->resolving(['images.example.test' => ['93.184.216.34']]);
Http::fake([
'https://images.example.test/*' => Http::response('', 302, ['Location' => 'https://10.0.0.5/a.png']),
]);
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])
->assertUnprocessable()
->assertJsonPath('errors.url.0', __('rules.media.remote.redirect'));
}
#[Test]
public function it_stops_reading_past_the_size_limit(): void
{
config()->set('media.upload.max_kb', 1);
$this->actingWithRole('editor');
$this->resolving(['images.example.test' => ['93.184.216.34']]);
// No Content-Length to go by: the limit has to hold while reading.
Http::fake([
'https://images.example.test/*' => Http::response(str_repeat('x', 4096), 200),
]);
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])
->assertUnprocessable()
->assertJsonPath('errors.url.0', __('rules.media.remote.too_large', ['max' => 1]));
}
#[Test]
public function a_url_that_answers_with_something_else_is_not_stored(): void
{
$this->actingWithRole('editor');
$this->resolving(['images.example.test' => ['93.184.216.34']]);
// Claims to be an image; the bytes say otherwise.
Http::fake([
'https://images.example.test/*' => Http::response('<html>nope</html>', 200, ['Content-Type' => 'image/png']),
]);
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])
->assertUnprocessable()
->assertJsonPath('errors.url.0', __('rules.media.not_an_image'));
$this->assertDatabaseCount('media', 0);
}
/**
* @return array<string, array{0: string, 1: string}>
*/
public static function notImages(): array
{
return [
'text renamed to .jpg' => ['photo.jpg', 'not an image at all'],
'HTML renamed to .svg' => ['logo.svg', '<html><body>not a vector</body></html>'],
];
}
#[Test]
#[DataProvider('notImages')]
public function it_refuses_an_upload_that_is_not_a_raster_image(string $name, string $contents): void
{
$this->actingWithRole('editor');
$this->post('/api/v1/media', [
'file' => UploadedFile::fake()->createWithContent($name, $contents),
], ['Accept' => 'application/json'])
->assertUnprocessable()
->assertJsonValidationErrors('file');
$this->assertDatabaseCount('media', 0);
}
#[Test]
public function an_svg_is_stored_with_whatever_could_run_taken_out(): void
{
$this->actingWithRole('editor');
$svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" onload="alert(1)">'
.'<script>alert(2)</script>'
.'<a href="javascript:alert(3)"><rect width="10" height="10" fill="#21759B"/></a>'
.'<image href="https://tracker.example.test/pixel.png"/>'
.'</svg>';
$response = $this->post('/api/v1/media', [
'file' => UploadedFile::fake()->createWithContent('logo.svg', $svg),
], ['Accept' => 'application/json'])
->assertCreated()
->assertJsonPath('data.mime', 'image/svg+xml')
->assertJsonPath('data.width', null);
$stored = Storage::disk((string) config('media.disk'))->get($response->json('data.path'));
$this->assertStringEndsWith('.svg', $response->json('data.path'));
$this->assertStringContainsString('#21759B', $stored);
foreach (['<script', 'onload', 'javascript:', 'tracker.example.test'] as $removed) {
$this->assertStringNotContainsString($removed, $stored);
}
}
#[Test]
public function an_upload_for_a_technology_lands_where_the_panel_puts_their_logos(): void
{
$this->actingWithRole('editor');
$this->post('/api/v1/media', [
'file' => UploadedFile::fake()->image('logo.png'),
'for' => 'technologies',
], ['Accept' => 'application/json'])
->assertCreated()
->assertJsonPath('data.path', fn (string $path) => str_starts_with($path, 'technologies/'));
}
#[Test]
public function it_takes_the_permission_of_the_record_it_is_for(): void
{
// A writer authors pages but only reads technologies.
$this->actingWithRole('writer');
$this->post('/api/v1/media', [
'file' => UploadedFile::fake()->image('logo.png'),
'for' => 'technologies',
], ['Accept' => 'application/json'])
->assertForbidden();
}
#[Test]
public function it_only_knows_the_records_it_is_told_about(): void
{
$this->actingWithRole('super_admin');
$this->post('/api/v1/media', [
'file' => UploadedFile::fake()->image('logo.png'),
'for' => '../../etc',
], ['Accept' => 'application/json'])
->assertUnprocessable()
->assertJsonValidationErrors('for');
}
#[Test]
public function it_wants_exactly_one_source(): void
{
$this->actingWithRole('editor');
$this->postJson('/api/v1/media', [])
->assertUnprocessable()
->assertJsonValidationErrors(['file', 'url']);
$this->post('/api/v1/media', [
'file' => UploadedFile::fake()->image('a.png'),
'url' => 'https://images.example.test/a.png',
], ['Accept' => 'application/json'])
->assertUnprocessable()
->assertJsonValidationErrors('file');
}
#[Test]
public function whoever_writes_pages_may_upload(): void
{
$this->actingWithRole('writer');
$this->post('/api/v1/media', ['file' => UploadedFile::fake()->image('a.png')], ['Accept' => 'application/json'])
->assertCreated();
}
#[Test]
public function whoever_only_reads_may_not(): void
{
$this->actingWithRole('viewer');
$this->post('/api/v1/media', ['file' => UploadedFile::fake()->image('b.png')], ['Accept' => 'application/json'])
->assertForbidden();
$this->assertDatabaseCount('media', 0);
}
#[Test]
public function it_refuses_a_url_before_fetching_it_for_someone_who_may_not_upload(): void
{
$this->actingWithRole('viewer');
$this->resolving(['images.example.test' => ['93.184.216.34']]);
Http::fake();
$this->postJson('/api/v1/media', ['url' => 'https://images.example.test/a.png'])
->assertForbidden();
$this->assertNoImageWasFetched();
}
#[Test]
public function the_panel_still_has_no_way_to_create_a_file_on_its_own(): void
{
$editor = User::factory()->create();
$editor->syncRoles(['editor']);
$this->assertTrue($editor->can('upload', Media::class));
$this->assertFalse($editor->can('create', Media::class));
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\RadarRing;
use App\Models\Project;
use App\Models\Technology;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
class TechnologyApiTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
protected function actingWithRole(string $role): void
{
$user = User::factory()->create();
$user->syncRoles([$role]);
$this->withHeaders($this->apiHeadersFor($user));
}
#[Test]
public function it_creates_a_technology_with_its_prose_in_several_languages(): void
{
$this->actingWithRole('editor');
$this->postJson('/api/v1/technologies?lang=es&with=translations', [
'name' => 'Kotlin Multiplatform',
'slug' => 'kotlin-multiplatform',
'category' => 'framework',
'radar_ring' => 'trial',
'color' => '#7F52FF',
'website' => 'https://kotlinlang.org/docs/multiplatform.html',
'description' => ['es' => 'Código Kotlin compartido.', 'en' => 'Shared Kotlin code.'],
])
->assertCreated()
->assertJsonPath('data.name', 'Kotlin Multiplatform')
->assertJsonPath('data.radar_ring', 'trial')
->assertJsonPath('data.description', 'Código Kotlin compartido.')
->assertJsonPath('data.translations.description.en', 'Shared Kotlin code.');
$this->assertSame(RadarRing::Trial, Technology::query()->sole()->radar_ring);
}
#[Test]
public function the_logo_is_a_path_from_the_upload_endpoint(): void
{
Storage::fake((string) config('media.disk'));
$this->actingWithRole('editor');
$path = $this->post('/api/v1/media', [
'file' => UploadedFile::fake()->createWithContent(
'angular.svg',
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>',
),
'for' => 'technologies',
], ['Accept' => 'application/json'])->json('data.path');
$this->postJson('/api/v1/technologies', [
'name' => 'Angular',
'slug' => 'angular',
'logo' => $path,
])
->assertCreated()
->assertJsonPath('data.logo', $path)
->assertJsonPath('data.logo_media.url', fn (string $url) => str_ends_with($url, $path))
->assertJsonPath('data.logo_media.alt', 'Angular');
}
#[Test]
public function a_partial_update_leaves_the_rest_alone(): void
{
$this->actingWithRole('editor');
$technology = Technology::factory()->create([
'name' => 'Laravel',
'radar_ring' => RadarRing::Assess,
'description' => ['es' => 'Framework PHP.', 'en' => 'PHP framework.'],
]);
$this->patchJson("/api/v1/technologies/{$technology->id}", [
'radar_ring' => 'adopt',
'description' => ['ca' => 'Framework PHP.'],
])
->assertOk()
->assertJsonPath('data.name', 'Laravel')
->assertJsonPath('data.radar_ring', 'adopt');
$this->assertSame('PHP framework.', $technology->fresh()->getTranslation('description', 'en'));
}
#[Test]
public function a_ring_can_be_cleared_to_take_it_off_the_radar(): void
{
$this->actingWithRole('editor');
$technology = Technology::factory()->create(['radar_ring' => RadarRing::Hold]);
$this->patchJson("/api/v1/technologies/{$technology->id}", ['radar_ring' => null])
->assertOk()
->assertJsonPath('data.radar_ring', null);
$this->getJson('/api/v1/technologies?radar_ring=none')
->assertOk()
->assertJsonCount(1, 'data');
}
#[Test]
public function slugs_are_unique_and_url_safe(): void
{
$this->actingWithRole('editor');
Technology::factory()->create(['slug' => 'react']);
$this->postJson('/api/v1/technologies', ['name' => 'React', 'slug' => 'react'])
->assertUnprocessable()
->assertJsonValidationErrors('slug');
$this->postJson('/api/v1/technologies', ['name' => 'Spring Boot', 'slug' => 'spring boot'])
->assertUnprocessable()
->assertJsonValidationErrors('slug');
}
#[Test]
public function one_still_in_use_cannot_be_deleted(): void
{
$this->actingWithRole('editor');
$technology = Technology::factory()->create();
Project::factory()->create()->technologies()->attach($technology);
$this->deleteJson("/api/v1/technologies/{$technology->id}")->assertForbidden();
$this->assertModelExists($technology);
}
#[Test]
public function a_reader_can_list_but_not_write(): void
{
$this->actingWithRole('viewer');
Technology::factory()->create();
$this->getJson('/api/v1/technologies')->assertOk()->assertJsonCount(1, 'data');
$this->postJson('/api/v1/technologies', ['name' => 'Flutter', 'slug' => 'flutter'])
->assertForbidden();
}
}

View File

@@ -103,6 +103,38 @@ class TokenEndpointTest extends TestCase
->assertJsonValidationErrors('grant_type'); ->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] #[Test]
public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void
{ {

View File

@@ -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']));
}
}