Replace the ad-hoc `public-uri-with-path` helper with `build-public-uri`
that uses the standard `u/ensure-path-slash` + `u/join` pattern already
used throughout the backend and frontend.
This makes OIDC redirect URI construction consistent with the rest of
the codebase while correctly preserving subpath configuration.
For hash routes (frontend URLs), use `u/append-query-param` to place
query params inside the fragment, matching the expected URL structure.
Add 6 new tests covering subpath edge cases:
- Trailing slash in public-uri
- Query params inside hash fragments
- No-subpath fallback
- Multiple redirect functions with subpaths
AI-assisted-by: qwen3.7-plus
AI-assisted-by: gpt-5.6-luna
Verify the OIDC state token once per callback and reuse the parsed
claims, removing the duplicate verification while preserving the
invalid-state error redirect.
Test that the trusted-origin middleware is registered in the root
router chain and ordered before auth and errors, and cover the OIDC
logout post-logout redirect with and without a trusted origin.
Warn at startup about malformed PENPOT_TRUSTED_ORIGINS entries, unify
the request stamp keyword spelling, and clean up the test helpers.
Closes#11625
AI-assisted-by: deepseek-v4.1-flash
Add an optional PENPOT_TRUSTED_ORIGINS setting listing alternate origins.
When a request Origin matches, the backend uses it as the public base for
links generated while serving that request: email links, asset URLs,
audit and webhook link fields, and redirects.
A global http middleware stamps the trusted origin on the request and
binds the config public-uri to it for the request extent. OIDC
authorization stores the origin in the signed state so the callback
(a navigation without Origin) can rebuild the token-exchange
redirect_uri and its redirect targets on the alternate domain.
Unlisted origins and requests without Origin are unchanged. Only
bare origins are supported, and server-to-server or queued work
keeps the canonical public-uri.
Closes#11625
AI-assisted-by: deepseek-v4.1-flash
The configuration docs claimed it is not possible to add someone to a
team without them accepting an invitation email, but the backend
auto-adds existing profiles to the team when the email verification
is disabled (create-invitation in app.rpc.commands.teams-invitations).
Replace the incorrect sentence with an accurate exception note, add
the missing disable-email-verification entry to the "Other flags"
reference, document the CLI-created profile auto-add behavior in the
docker getting-started guide, and describe the shortcut in the
developer authentication docs.
Closes#11424
Reported in #11376
AI-assisted-by: glm-5.3-flash
* 💄 Unify copy in the shortcuts settings, modals and sidebar
Use one term per concept across the customizable shortcuts UI: "Custom"
and "Customize" for user changes, "shortcut" for the keys, and "Reset"
with "default" to go back to the defaults.
Rewrite the conflict message so it says what saving does, fix typos and
grammar, unify the empty states and rename the font size actions.
Give the reset all button, the search placeholder, the import column,
the invalid import error and the edit button aria-label their own keys,
and update the e2e tests to the new copy.
Closes#12173
AI-assisted-by: claude-opus-5-5
* 💄 Reword the shortcuts sidebar link and custom empty state
The sidebar link no longer names a settings section that does not exist
in Penpot. The empty state of the custom tab now says there are no
custom shortcuts and points to the All tab by name.
AI-assisted-by: claude-opus-5-5
Switching sidebar tabs unmounts the layers panel. The search text and
filters lived only in component state, so on return the still open
search bar was treated as a new search and cleared. Keep that state per
file in a session atom, as the assets panel already does.
Closing or clearing the search mounts the tree container again, but the
restore hook only ran on mount or page change, so the tree went back to
the top. Let use-restore-scroll take an active? flag so each list
restores its own position whenever it is shown again, and drop the
saved search position on close so a new search starts at the top.
Closes#11972
AI-assisted-by: claude-opus-5-5
Setting the local file back as the tokens source, or unlinking the
library that was the source, stored the file's own id in
:tokens-source. ensure-tokens-lib only created a tokens-lib when
:tokens-source was nil, so a file without a tokens-lib never got one,
and every token change was dropped without any error.
Now ensure-tokens-lib also creates the tokens-lib when the source is
the file itself, which fixes files already in that state, and
set-tokens-source removes the attribute instead of storing the file's
own id.
Closes#12180
AI-assisted-by: claude-opus-5-5
* 🐛 Fix nested libraries vanishing from the libraries modal
The "This file" tab nests each linked library under a linked library
that uses it, taken from the team shared files. A library with such a
parent was removed from the top level and only drawn under its parent
if the parent was at the top level itself. So:
- In a chain of two or more levels (file -> A -> B -> C), C was lost.
- In a cycle (A uses B and B uses A), both had a parent, none was at
the top level, and both were lost.
The libraries showed while the shared files were loading, and vanished
when they arrived.
Build the list as a tree instead: direct libraries and libraries not
used by any other linked library are roots, nested libraries follow
their parent at any depth, and each library is added only once, even
when the dependencies form a cycle.
AI-assisted-by: claude-opus-5-5
* ♻️ Move the linked libraries tree to sort-linked-libraries
Extract the code that orders the linked libraries as a tree from the
file tab to a public function, so it can be tested on its own. Add
tests for a cycle with a direct root, a cycle with no root, a deep
chain and no dependencies yet.
AI-assisted-by: claude-opus-5-5
* ♻️ Use connected-to to find library parents when sorting
The caller already computes :connected-to for each linked library, the
reverse index of the dependencies map. sort-linked-libraries rebuilt
the same relation from dependencies. Read it from :connected-to
instead, so the function takes only the libraries and the component
memo no longer depends on dependencies.
Reword the cycle-without-root test to say it models the unknown direct
flag, and add tests for a diamond, a self-dependency, a cycle of three
without root, a parent that is not a linked library, and for no
library being lost or duplicated.
AI-assisted-by: claude-sonnet-5-5
---------
Co-authored-by: Andrés Moya <andres.moya@kaleidos.net>
fix-deleted-fonts committed text and typography changes even when no
replacement font existed, so opening a page with a missing custom font
rewrote identical content and cleared position-data on every visit.
Only emit changes when the remapped content or typography actually
differs. Add focused unit coverage for the no-replacement and remapped
paths.
Closes#12151
Template links (#?template=<url>) keep their params in a bare "#?"
fragment. Since routing moved to the query string, only "#/" legacy
hashes are translated, and the root route was an empty query string,
so the template param was dropped and the dashboard opened without the
import dialog. Route a "#?" fragment as the query token, and treat a
query without a screen param as the root, as before.
Logged out users also lost the template: get-teams requires a session
and its 401 ended the flow before the params were kept for after login.
Skip it for the anonymous profile. This part already failed on main.
Closes#12127
AI-assisted-by: claude-opus-5-5
Remove per-side stroke widths from both Inspect geometry panels so
Stroke is the only section that lists them. Keep dimensions, position,
corner radii, and transforms in Size and position.
Add rendered-component regressions for boards, rectangles, multiple
selection, uniform strokes, and CSS declarations.
Closes#12026
AI-assisted-by: GPT-6
Signed-off-by: Danial Shirali <danialshirali16@gmail.com>
Signed-off-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
Stroke width tokens used to be applied to the single `:stroke-width`
token attribute. Applying tokens per side replaced it with the four
`:stroke-width-*` attributes, but no migration moved the existing
references, so files written before 2.19 look unapplied: the token
pill stays off, the Design tab shows a plain number and propagation
finds no update action.
Add migration 0031, which copies the legacy reference to every side,
keeps any side reference already present and drops the old key. It
covers page and component shapes.
Closes#12016
AI-assisted-by: deepseek-v4.1-flash
* ✨ Take changelog highlights from the release highlight label
The 🚀 section was picked by the agent on each run and kept
forever, so a regeneration could add highlights product did not choose
and nobody could drop one. Make the `release highlight` label the only
source, as `breaking change` already is for 💥, and have the report
flag 🚀 entries that do not match the label.
AI-assisted-by: claude-opus-5-5
* 📚 Update changelog for 2.19.0
Regenerate the 2.19.0 section from the milestone with the
update-changelog skill. Add the 79 closed issues that were missing,
drop entries labelled `no changelog` and #9263, which is not in the
milestone, and take 🚀 from the `release highlight` label.
AI-assisted-by: claude-opus-5-5
Wrap every id prop in the comp-processors warn logs
with str so the log shows plain text instead of
the #uuid reader form. Also use :hint instead of
:msg and lowercase text, as the rest of the system
does. No logic changes, logging only.
AI-assisted-by: muse-spark-1.3
* 🐛 Show the size badge on paths and straight lines
The badge was hidden when the shape was smaller than the badge in
either side. Flat paths have a zero width or height, so they never
showed it, and straight two-point paths hid it on purpose.
Paths now only compare their longest side with the badge width. A
straight line compares its length, and the badge runs along the
line, rotated with it and kept upright. Near the bottom of the
viewport the badge moves to the other side of the line.
The badge now reads the size from the shape :selrect, like the
measures inputs, so a rotated path shows the same values in both
places. The three badge branches share a single render block.
AI-assisted-by: claude-opus-5-5
* ♻️ Extract the size badge layout and add tests for it
Move the badge geometry out of selection-size-badge* into the pure
size-badge-layout function, so it can be tested without rendering.
The component now only picks the color and draws the result.
Add unit tests for rects, flat paths and straight lines: when the
badge hides, where it sits, how it rotates and how it moves when it
reaches the bottom of the viewport. Keep the line rotation between
-90 and 90 degrees instead of returning 360 for lines drawn leftward.
The e2e test still expected straight lines to hide the badge. It now
checks that the badge shows and follows the line angle.
AI-assisted-by: claude-opus-5-5
* ✨ Show the length of straight lines in the size badge
A straight two-point path is measured by its length, not by the box
around it, so its size badge now shows the length with two decimals at
most instead of width x height. Other shapes and paths keep width x
height.
The shorter text makes the badge narrower, so it also shows on shorter
lines; the rule is the same, the badge needs the line to be at least as
long as the badge.
Relates to #12064
AI-assisted-by: claude-opus-5-5
* 🐛 Hide the path size badge when the path is lower than it
Paths other than a straight line only hid the size badge when their
longest side was narrower than it, so a wide but low path, or a narrow
but tall one, still showed it. Flat paths no longer need that special
case, since straight lines get their own length badge. Use the same
rule as any other shape: hide the badge when either the width or the
height of the path is smaller than the badge.
AI-assisted-by: claude-opus-5-5
Check a milestone against the Main project board with
scripts/project-anomalies.py (check writes
tmp/<MILESTONE>-ANOMALIES.md): open issues with merged PRs,
milestone mismatches either way, needs triage labels and
unassigned non-community issues. Outsiders resolve in bulk;
only a missing milestone auto-fixes, on confirmation.
Teach scripts/gh.py batched issue lookup (issue subcommand),
milestone on prs output, plus assignees and projects fields,
and retry transient HTTP 504s.
AI-assisted-by: muse-spark-1.3
Move the inline programs out of the update-changelog skill into
scripts/changelog.py (check-merged, cross-ref, report) and shrink
the skill to the workflow alone.
Teach scripts/gh.py to retry transient HTTP 504s, resolve lookups
in batches of 50 (new issue subcommand, milestone on prs output),
and report 💥 entries without the breaking change label.
Add the nine missing 2.17.0 changelog entries found while
trying the new flow.
AI-assisted-by: muse-spark-1.3
In list view the height of each component row depended on its
thumbnail, which renders deferred. When the Assets tab mounted again,
rows were shorter until the thumbnails appeared, so the restored
scrollTop landed several rows further down and scroll anchoring kept
it there. Reserve the thumbnail height in the row grid so rows keep
their size from the first render.
Closes#11971
AI-assisted-by: claude-opus-5-5
Adding a flex or grid layout to a board with children derives the
paddings from the distance between the board and its children. A child
that overflows the board, or floating point noise after "Resize board
to fit content", gave negative paddings. The shape schema rejects them
since paddings must be non-negative, so update-file failed and autosave
stopped. Clamp the derived paddings to 0, as the derived gaps already
are.
Closes#12107
AI-assisted-by: claude-opus-5-5
The tokens list container stays mounted when another token set is
selected; only the restore key changes. With no saved position for the
new set, use-restore-scroll did nothing, so the list kept the scroll
position of the previous set. Scroll to the top when nothing is saved
for the key. Freshly mounted panels are already at the top, so Layers
and Assets do not change.
Closes#11973
AI-assisted-by: claude-opus-5-5
* 🐛 Sanitize smuggled SVG on binfile import
Route imported SVG storage objects through the SVG sanitizer on
all three binfile paths (v3, v1, v2), closing the second bypass
of GHSA-ffhp-m958-qxvr. Integrity checks still run on the raw
bundle bytes first; only the persisted copy is the sanitized one.
Adds a shared sanitize-imported-svg helper with unit tests and a
v3 export-tamper-import test proving smuggled scripts no longer
survive the import.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Normalize SVG content-type on binfile import
Close the residual GHSA-ffhp-m958-qxvr bypass where a crafted
content-type spelling (uppercase, parameters) skipped the import
sanitizer. Detection now uses a shared case-insensitive predicate,
stored values are canonicalized, and the v3 re-read honors the
import size limit.
Extends the tamper tests to obfuscated spellings and adds
exhaustive v1 coverage, including the tempfile branch.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Gate binfile content-type on known storage types
Define the complete set of storage content-types in
app.common.media and enforce it on the binfile import schema, so
unknown types fail closed instead of passing through. Detection
keeps a single normalization at the boundary with an exact
predicate, and the string helpers use cuerdas.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Keep only produced types in storage-object-types
Drop apng, avif, penpot and plain text from the set: no flow
stores them, they only exist in the extension mapping table.
Every member must have a producing code path; unproduced types
stay out until some flow actually stores them.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Reject unknown content-type on v1 binfile import
Enforce the storage content-type allowlist on the v1 import
path, like v3 already does via schema. Bundles declaring types
outside the set, or none at all, fail closed with the same
media-type-not-allowed error as uploads. The sanitize branch is
explicit: SVG bytes are sanitized, anything else passes through.
AI-assisted-by: muse-spark-1.3-contributor
Renaming a token with "Remap tokens" grouped the references by container
and wrote the union of their attributes onto every shape in it. A shape
with a stroke width token on one side got the token on all four sides
when another shape in the same container had it on every side.
Group the references by shape, so each shape is only renamed on the
attributes it already had the token on.
Closes#12075
AI-assisted-by: claude-opus-5-5
Since stroke widths can be set per side, stroke width tokens are stored
in the four per-side attributes, not in :stroke-width. The inspect stroke
panel still read :stroke-width for Border width and had no token lookup
for the per-side widths, so it showed the raw value instead of the token.
Read the token of each side for the per-side widths, and the token shared
by every side for Border width. Files that are not migrated yet keep the
token in :stroke-width, so Border width still falls back to it.
Closes#12029
AI-assisted-by: claude-opus-5-5
* 🐛 Show the top stroke width in the old render
With the old render, the stroke width input showed "Mixed" when the four
sides had different widths or tokens. The old render only draws the top
side, and the input must show it as the global stroke width.
Move the input value and token logic into two pure functions. When per
side strokes are disabled, both return the top side: its width and its
token, or no token when the top has none.
Closes#12063
AI-assisted-by: claude-opus-5-5
* 🐛 Add e2e tests for the stroke width input in both renders
Cover the global stroke width input when the four sides differ. With the
new render it shows Mixed. With the old render it shows the top side:
its width, or its token when the top has one.
The old render cannot create per side data from the UI, and switching
the render reloads the page and drops the test state. The tests patch
the existing stroke token fixture instead, appending the per side fields
to a rectangle and a board.
Relates to #12063
AI-assisted-by: claude-opus-5-5
The nested group wrapper only turned into a wrapping flex row when it
had a direct button child. Since the pills are wrapped in the DS
tooltip (#11900), the button is no longer a direct child, so pills in
nested groups were stacked one per row. Match pills as "any child that
is not a group" instead, so the rule no longer depends on the pill's
own markup.
Also drop the .token-pill rules of this module: CSS modules prefix
them with the token_tree scope, so they never matched the pill button.
Closes#12099
AI-assisted-by: claude-opus-5-5
When the import modal content moved to import-files-stage* (#9958), the
"error list or file rows" if became a when followed by rows that always
render. A non-.penpot file yields an analyze error entry with no name,
so the modal showed an empty error row below the error list. Render the
file rows only when the error list is not shown, as before.
Closes#12116
AI-assisted-by: claude-opus-5-5
Loading a plugin closed every non-background plugin and then emptied the
runtime registry, dropping background plugins (allowBackground, such as
the MCP plugin) that were left running. The registry is what routes a
plugin iframe's postMessage traffic to its plugin and what ɵunloadPlugin
searches, so after the user opened any other plugin the MCP plugin never
received task requests from its UI (MCP tool calls timed out) and could
no longer be unloaded.
Keep background plugins in the registry and drop only the plugins that
were closed.
AI-assisted-by: claude-opus-5.5
Signed-off-by: breken-ai <312387581+breken-ai@users.noreply.github.com>
Co-authored-by: breken-ai <312387581+breken-ai@users.noreply.github.com>
Giant legitimate exports hold hundreds of MB of JSON across
hundreds of thousands of KB-sized entries without tripping any
per-entry cap, so the 200MiB cumulative default rejected them out
of the box. Raise it to 500MiB; the 20MiB per-entry cap still
bounds single-entry inflation. Pin the floor with a regression
test.
AI-assisted-by: muse-spark-1.3-contributor
The S3 file download opens the temp file with CREATE_NEW, so it
fails when the path already exists. tempfile creates the empty
file on reserve, which made every large download depend on the
SDK deleting the placeholder between its own retries.
Add tmp/temp-path, which reserves a unique path and registers
cleanup without creating the file, and use it for S3 downloads.
The old retry on FileAlreadyExists stays as a safety net for a
name clash.
AI-assisted-by: muse-spark-1.3-contributor
Delete the team-scoped file, project and pin relations on leave-team
and delete-team-member, require a live team membership in the file,
project, search and listing permission queries, and clean
pre-existing orphan rows with a migration. Former members now get
not-found on file actions instead of keeping read and rename access
(GHSA-v9r9-h77c-55m2).
AI-assisted-by: muse-spark-1.3-contributor
actions/create-github-app-token v3 deprecates the app-id input in
favour of client-id. Switch to the new TRIAGE_APP_CLIENT_ID secret.
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
Drop the equals-sign banners from the CLJS throwable formatter,
so new frontend error reports are born without them. The JVM
branch never printed them. Adds a test asserting full reports
carry sections but no separator runs.
AI-assisted-by: Muse Spark 1.3 Free
* 🐛 Keep interactions of flex children when syncing components
The flex-child sync between main and copy children propagated every
attribute in swap-keep-attrs, which includes :interactions. Since
:interactions has no sync group, the touched check never protected it,
so any change that reflowed a flex component (like toggling a child's
visibility) overwrote the copy children's interactions with the main's.
Closes#11746
AI-assisted-by: claude-opus-5
Signed-off-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com>
* 🐛 Add another test and simplify comments
---------
Signed-off-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com>
Co-authored-by: Shreyash Agare <264953665+ShreyashAgare26@users.noreply.github.com>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
* 🐛 Sanitize SVG namespace-prefixed elements and attributes
The SVG sanitizer compared qualified tag and attribute names against
its blocklists. The XML parser keeps the namespace prefix in the
keyword name (❌script), so a script element written with a prefix
bound to the SVG namespace passed sanitization untouched and was
later served as image/svg+xml, executing in the Penpot origin.
Match tags and attributes on the local name (the part after the last
colon) instead, so dangerous elements and attributes are removed no
matter which prefix they use. Legitimately prefixed attributes such
as xlink:href keep working.
Closes#12071
AI-assisted-by: glm-5.3-flash
* ♻️ Cover prefixed SVG vectors and hoist sanitizer blocklist
Apply the code review findings on top of the sanitizer fix:
- Add a test for the javascript: href vector using a prefix bound to
the xlink namespace, the other payload variant the old exact-key
check missed.
- Add a test asserting that legitimately prefixed attributes such as
xml:space are preserved by the local-name matching.
- Hoist the dangerous-tags blocklist next to the other sanitizer
policy defs and bind the keyword name once in local-name.
Relates to #12071
AI-assisted-by: glm-5.3-flash
Default is now no `!` for new functions. Existing `!`
stays as legacy until its gradual refactor.
Allowed cases live in clojure/idioms: mutating public
app.db API and functions working over transients. Any
further case must be registered there first.
AI-assisted-by: muse-spark-1.3-contributor