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 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>
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>
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer
collective, backed by the Pireon OAuth identity server. The public site is a
separate, server-rendered application consuming /api/public/v1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>