The production stack hands the containers only the variables it lists,
and PIREON_REDIRECT_CLIENT_ID, its secret, PIREON_REDIRECT_LOGIN and the
two webhook secrets were not among them: set in Dokploy, they never
reached the application, so «Entrar con Pireon» never showed up.
Compose also passes an unset variable as an empty one, so an empty
PIREON_REDIRECT_URI now falls back to APP_URL/auth/callback (the redirect
flow had none otherwise), and an empty PIREON_REDIRECT_LOGIN no longer
hides the button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Until now, an account switched off or a token revoked at the identity
server kept working here: bearer tokens until the introspection cache
ran out, panel sessions until they ended, and machine tokens for good,
as they only look at the local account.
POST /api/v1/webhooks/pireon takes user.deactivated (the account goes
inactive, which stops its machine tokens and the panel; its sessions
end and its cached tokens are forgotten) and token.revoked (sessions
and cached tokens go). Pireon signs each delivery with the secret of
the client that registered the webhook, so either PIREON_WEBHOOK_SECRET
(password client) or PIREON_REDIRECT_WEBHOOK_SECRET («Entrar con
Pireon») verifies it; with neither, the endpoint refuses everything.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under the e-mail and password form, a button takes the redirect flow
(Authorization Code + PKCE), so the password is only ever typed at the
identity server. The routes for it were already there; the page had no
way to reach them.
A Pireon client allows one grant type and the panel's is a password
client, so the redirect flow can use its own: PIREON_REDIRECT_CLIENT_ID
and PIREON_REDIRECT_CLIENT_SECRET (the main client when they are empty).
The button shows up once that client is set, or with
PIREON_REDIRECT_LOGIN=true. Each session renews its token with the
client that issued it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The project form gets «Crear en TaskStream» while the project has no
board, and `taskstream: true` on a PATCH does the same through the API.
A refusal leaves the edit unsaved; a project with a board keeps it, and
`taskstream_key` without the flag is still ignored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A project with a board now carries its key, its address and the address
of the board's public showcase in the public API, so the website can show
its roadmap, releases and tickets for newcomers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A "TaskStream" section on the create form (toggle and optional key,
suggested from the slug) opens the board before saving, as the person
signed in, with the Pireon token of their session. A refusal shows on the
key field and a TaskStream outage as a notification; either way nothing is
saved. The created notification carries the link, and the edit page has
an "Open in TaskStream" button.
Documented in the user manual (and its PDF) and the README.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`taskstream: true` on POST /v1/projects also creates the project in
TaskStream, public, as the caller (their Pireon token is passed on), and
the answer carries `taskstream_key` and `taskstream_url`. The key is
`taskstream_key` or one made from the default-language slug.
The board is opened before the project is saved: if TaskStream refuses
it, nothing is created here and the reason comes back as a validation
error (502 when TaskStream does not answer). Machine tokens need the
server's own TASKSTREAM_TOKEN. Updates ignore the flag, and duplicates do
not inherit the link.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Events are {resource}.created, .updated, .deleted and .restored for content,
the inbox, redirects, repositories and media, taken from the audit log so the
two agree on what happened. Subscriptions take wildcards. Users, tokens,
webhooks and settings are never sent, nor what the application does by
itself; the inbox's own words only with personal data enabled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Create one and see its secret once, send a test, change the secret, and read
each delivery with what the other end answered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tags are named in every language and made from the page form; the byline is
the member who signs a post on the site. The public API sends both, filters
posts by tag and lists the tags in use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each field's original sits beside the field to write, and a translation that
fell behind shows what the original said when it was translated and what it
says now, word by word.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A reply starts from a template filled in the language the person wrote in,
is sent by the application when it has a mailer that delivers or handed to
the replier's mail program otherwise, and is recorded on the submission.
The same is available through the API.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The most read, reads per language with a daily chart, and published content
nobody opened, each compared with the period before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A month of publications, scheduled dates, content coming off the site, and
calls opening and closing, each linked to its form.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dashboard widget lists what expires in the next two weeks, and a daily
command reminds whoever edits it a week and a day ahead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy keeps the content, stack and team, but is unpublished, out of any
review, and gets a title marked as a copy and a free slug. It replaces
Filament's replicate action, which left the copy without a slug and still
published. The same copy is available through the API.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Giving a record a future date was recorded as "unpublished", and announced
to webhooks as such. The history now compares before and after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Written from model events, spatie's role events and panel sign-ins, only for
what a person did, with secrets masked; pruned after a year.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fed by the records themselves, delivered only to public HTTPS addresses
checked at every attempt, retried, and switched off after repeated failures.
A submitter's details travel only to webhooks that opted into them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token acts as the person who made it, within the permissions it was given
and those they still hold, checked on every request. Permission checks now go
through one gate rule, since spatie's answered first and never asked the
token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each operation is dispatched as a request of its own, so a batch is held to
the same validation and permissions as the calls it contains.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Revisions now call publishing only what publishes: a project's status is
whether the work is active, and a service is published by its flag.
The fake identity server answers by token, so a test can sign in a second
person.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Media::referencedPaths() is what `content:prune --media` asks before
deleting a file, and it did not know about two kinds of image a page
shows:
- A technology's logo. Every logo on the radar looked orphaned, and one
prune would have deleted all of them.
- Images inside rich text — a page's body, a project's description,
decisions and postmortem — whether attached in the panel's editor or
uploaded through the API and written in as <img src>. They are now
found in the HTML, in every language, bin included.
The page body's editor also states where attachments go — the media
disk, pages/content, public — instead of relying on Filament's
defaults: the HTML reaches the frontend as it is, and a private file
would be a broken image there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving a page with a category failed with "El campo categoría no está
en la lista de valores permitidos" — for every category, not only the
one being tried. Filament builds such a list through a query, which
handles `->relationship('category', 'name->es')`, but validates a choice
by reading the title back off the record as a property,
`$record->{'name->es'}`, which does not exist. No label, so every
option was refused.
The same pattern was in seven more places: a call's, a testimonial's,
a talk's and a repository's project, a testimonial's service, and the
page and application filters. TranslatedOptionLabel gives Filament the
record's own translation instead, in the language of whoever uses the
panel; the JSON path stays for search and ordering.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/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>
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>
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>
/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>
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>
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>
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>
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>
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>
CI (.github/workflows/tests.yml) reuses docker-compose.test.yml verbatim for
the Postgres job rather than re-describing that environment in YAML, so the
Docker image stays the single source of truth for it.
Fixes a real mismatch found while wiring PHP versions into the workflow:
composer.json declared "php": "^8.3", but the locked Symfony packages require
>=8.4.1 — installing on 8.3 would fail at `composer install`, exactly what
the Dockerfile's own comment already warned about. Bumped the constraint and
resynced composer.lock's content-hash (no package versions changed).
The pre-commit hook (.githooks/pre-commit, activated via
`git config core.hooksPath` in post-autoload-dump) runs `pint --dirty --test`
and refuses to commit unformatted files rather than silently rewriting them.
The accessibility pass only touches this app's own hand-written Blade views,
not Filament's components: aria-hidden on icons that repeat adjacent text,
an aria-label on the one icon that is the only signal of a health check's
severity, rel="noopener" and a labelled announcement on preview links that
open in a new tab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>