Commit Graph

50 Commits

Author SHA1 Message Date
d10ce1ca57 Take Pireon's webhooks: a deactivation or a revocation counts at once
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
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>
2026-10-16 17:01:57 +02:00
ec4f687ca8 Offer «Entrar con Pireon» on the panel's login page
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
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>
2026-10-16 12:46:07 +02:00
3c119d425e Open a TaskStream board for a project that has none yet
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
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>
2026-10-12 20:21:38 +02:00
7e7d2847fe Point the public site to each project's TaskStream board
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
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>
2026-10-08 19:13:56 +02:00
b32d6bf001 Open a TaskStream board from the panel's project form
Some checks failed
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Tests / lint (push) Has been cancelled
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>
2026-10-07 23:01:55 +02:00
cdfc8763a5 Document TaskStream boards in the README and .env.example
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:20:23 +02:00
d30dd971d7 Open a TaskStream board when a project is created 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
`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>
2026-10-07 21:12:36 +02:00
9390db18c2 Send a webhook for every change a person makes to a record
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
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>
2026-10-01 14:32:54 +02:00
d4cf4a66b7 Manage webhooks from the panel
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
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>
2026-10-01 11:44:11 +02:00
d80ed1a3e8 Name dynamic translation keys the way the catalogue test reads them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:31:47 +02:00
9f970808ca Tag blog posts and sign them with a member
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
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>
2026-10-01 11:01:18 +02:00
6fc600e6a1 Add a translation assistant to the panel
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>
2026-10-01 10:53:31 +02:00
76d022053b Answer the inbox from the panel, with templates
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>
2026-10-01 10:45:47 +02:00
697d333ad4 Add a statistics page to the panel
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>
2026-10-01 10:19:19 +02:00
8d0f81f9c5 Add an editorial calendar to the panel
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>
2026-10-01 10:17:46 +02:00
773d3901c6 Warn about calls closing and content coming off the site
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>
2026-10-01 10:16:24 +02:00
366568b793 Duplicate records as drafts, and preview them from the form
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>
2026-10-01 10:14:51 +02:00
70521d4bc9 Tell scheduling apart from unpublishing in the history
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>
2026-10-01 10:09:03 +02:00
1fe1e67773 Keep an audit log of who did what, and how they came in
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>
2026-10-01 10:08:01 +02:00
9bdbf834f6 Send signed outgoing webhooks
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>
2026-10-01 09:42:39 +02:00
f93775e549 Issue machine tokens with limited permissions and an expiry
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>
2026-10-01 09:21:37 +02:00
0119004f2c Run batches and import JSON or CSV through the API
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>
2026-10-01 08:58:36 +02:00
6b3517743a Search the public site through the API
Full-text on Postgres, stemmed in the language searched; word matching
elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:53:34 +02:00
68c5bd4274 Report site health and pending translations through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:32:45 +02:00
5b46436641 Review, publish, preview and undo changes through the API
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>
2026-10-01 08:29:48 +02:00
4c4a2b3299 Browse and clean up the media library 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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:55:03 +02:00
8d0f2e3639 Manage redirects, missing URLs, repositories and settings through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:51:00 +02:00
4c65604f04 Manage calls and the inbox through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:42:59 +02:00
b02464a7ed Manage clients, testimonials and talks through the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:38:58 +02:00
96747abdc3 Keep technology logos and images inside text safe from pruning
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
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>
2026-09-29 23:09:25 +02:00
3daa40570b Accept the option picked in selects of translatable records
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
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>
2026-09-29 21:21:43 +02:00
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
c8ba8d7b14 update
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
2026-08-21 09:14:51 +02:00
618d3a56ad Add CI, a pre-commit hook, and an accessibility pass on the panel's own views
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>
2026-08-20 08:35:17 +02:00
3897113313 Initial commit: Pireon Admin — headless admin panel and public API
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>
2026-08-19 17:55:27 +02:00