The 🔒 section is generated from advisories instead of being written
by hand: an advisory becomes an entry when its patched_versions include
the release version, so unpublished (triage/draft) advisories are valid.
- gh.py advisories --patched-version filters on the exact token of
vulnerabilities[*].patched_versions; the list output keeps the summary
schema.
- changelog.py security renders the entries sorted by GHSA id, links the
merged milestone PRs labelled "pr pt" that reference the GHSA id and
warns when a reference has no matching patched version. The subsection
follows 🚀 (or 💥, or the version header) and is removed
when the version has no advisories.
- Tests cover the filter, the entry rendering, PR discovery and the
subsection placement.
- Memories and the update-changelog skill document the contract.
- CHANGES.md: add 2.19.1 with its security fixes and the remaining bug
fix.
AI-assisted-by: deepseek-v4.1-flash
The changelog generator copies the note lines verbatim, so a note
hard-wrapped at 80 columns ends up wrapped in CHANGES.md. Record
that the breaking-change note must keep one line per paragraph or
bullet, in both the issue and changelog workflow memories.
AI-assisted-by: inkling-2-small-free
* 🐛 Require thread access when marking threads as read
mark-all-threads-as-read took any list of thread ids and wrote the read
status of the caller on each of them, without checking that the caller
can access the file that owns the thread. It was the only comment
mutation that did not check permissions, and it took the ids as an
unbounded array, so a single request could keep a database connection
and a transaction open for a long time.
The rpc now resolves the threads in one query and checks the comment
permissions of every distinct file, answering not-found when a thread
does not exist or is not accessible. Both cases answer the same error
on purpose, so the response cannot be used to tell existing threads
apart.
The status rows are now written with a single insert, keeping the
modified-at date one second in the future that makes the thread count
as read. Repeated ids are deduped, the command returns nil again like
the sibling commands with no result, and the schema limits the number
of threads per call to 500 while the dashboard sends the ids in batches
of that size.
Tests cover access rejection, unknown threads, mixed batches that write
nothing, repeated ids, empty lists, marking twice, and the frontend
batch split.
Relates to GHSA-j4vc-cmf6-jhr7
AI-assisted-by: minimax-m3.1-flash
* 🐛 Refresh the unread list when a batch fails
The dashboard sends the thread ids in batches of 500 and each batch
commits on the server. A failure in a later batch left the unread list
stale: the re-read only ran when every batch succeeded, so the threads
already marked as read stayed visible as unread.
The re-read now runs in both paths. On failure the event is emitted
before the error is propagated, so the error toast still shows and the
success toast stays limited to a full success.
The backend also answers an empty list without opening a transaction,
since there is nothing to check and nothing to write.
Relates to GHSA-j4vc-cmf6-jhr7
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Restrict the team invitation token and list to admins
The token of a team invitation is a bearer credential: whoever holds it
accepts the invitation with the role of the invitation row. The RPC that
mints it only required edition permissions, while the rest of the
invitation commands require admin, so a team editor could get the token of
any pending invitation, including the ones that grant the owner role.
The token now requires admin, and the token of an owner invitation is
restricted to the team owner: an admin cannot create nor promote an owner
invitation, so it cannot keep its token either. It reuses the error code
already used for that rule.
Minting the token now leaves an audit event named as an action, carrying
the granted role and the id of the invitation, so the extraction does not
go unnoticed.
The list of pending invitations is admin only as well: it carries the
email of the invited person and the role offered, and the dashboard was
painting those emails to any member. The frontend catches the permission
error and shows the permission notice instead, and the copy action is not
offered for an owner invitation, which the backend rejects for an admin.
The five invitation commands now answer with the same permission error,
so they speak one language.
Relates to GHSA-4mmc-j8rh-74pf
AI-assisted-by: minimax-m3.1-flash
* 🐛 Clear the cached invitations and gate the list on team admin
The pending invitations are a team admin only list. The dashboard still
offered the entry to every member and keyed the section on the invite
policy of the organization, so a team admin blocked by that policy saw
the no-permission notice although the backend lets them list.
- `fetch-invitations` answers a denied refetch with an
`invitations-fetched` event carrying an empty list, so a demoted admin
does not keep the cached emails and the header no longer paints the
invite button from a stale list.
- The header tabs and the team options dropdown hide the invitations
entry without `:is-admin`, which already includes the team owner.
- `invitations-section-content` keys the section on `:is-admin`, and the
invite policy only gates the invite actions: resending keeps the
`can-invite?` check, deleting and row selection only need team admin.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Apply the share-link page scope to the comment queries and writes
A share link can be restricted to a subset of the pages of a file, and
the comment queries enforced that scope. But the RPC that resolves the
profiles of the commenters authorized the request with
check-comment-permissions! and dropped the returned perms, so its query
filtered only by file id. The holder of a page scoped link got the id,
email, fullname and photo of every profile that commented on any page
of the file, including the pages the owner left out of the link.
The query now takes the pages of the share link and only selects the
authors of the threads on them. The profile of the caller is still
added, so the viewer keeps its own avatar.
The scope was missing as well on the comment writes that take a page id
or a thread id: create-comment-thread accepted a page outside the link,
create-comment accepted a thread of an unshared page, and
update-comment-thread, update-comment-thread-position and
update-comment-thread-frame accepted a thread of an unshared page. They
now raise not-found, the same way the comment queries already did.
Replying to a thread of an unshared page also sent the emails to the
thread participants.
The page scope rule now lives in one place: share-link-pages returns
the pages granted by a share link and check-page-scope raises not-found
when a page is outside of it, so get-comment-threads reuses the helper
instead of checking the pages inline.
The docstring of get-profiles-for-file-comments said that it returns
the participants of all the comment threads of the file, which is not
true anymore for a share link. The docs are public, so the text and
the changes list are updated.
The tests of the share link page scope are hardened: the setup asserts
that the comment threads are created, the membership case has a second
commenter so it can no longer pass for the wrong reason, and the link
without pages, the allowed paths of the thread updates and the
who-comment=team denial are covered.
Relates to GHSA-89cq-3wv7-987r
AI-assisted-by: minimax-m3.1-flash
* 🐛 Enforce the share-link page scope on the remaining comment writes
Check the page scope of the share link before mutating in the four
comment writes that still skipped it: update-comment-thread-status,
update-comment, delete-comment-thread and delete-comment. The check
runs before the ownership checks, so an out-of-scope write fails with
not-found and the response does not reveal that the comment or thread
exists. Load the file in create-comment only after the permissions and
page scope checks, matching create-comment-thread, so a rejected reply
does not load the file data.
Cover the denial and the in-scope paths of the four commands, and the
fail-closed case of a link without pages on the comment reads. Move
the outsider thread creation after the profiles assertion so the
caller-union assertion fails when the union is removed. Document the
page scope invariant in the backend permissions memory.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Escape SVG values and sanitize every SVG upload
The SVG sanitizer removed dangerous elements and attributes by name and
then wrote the tree back with clojure.xml/emit, which escapes nothing.
A payload hidden in an attribute value or in a text node became live
markup again: an id of "x' onload='alert(1)'" was stored as a real
onload handler, and an entity encoded script tag was stored as a real
script element.
Write the filtered tree with a serializer that escapes text and
attribute values, and route every path that stores an uploaded SVG
through it. The thumbnail writers (create-file-object-thumbnail and
create-file-thumbnail) and the organization logo stored the upload
untouched, so they could plant live markup in a public bucket.
The filter only knows about SVG elements, so it also accepted
documents that are not SVG at all: a page with an iframe or a meta
refresh was stored as an image. The sanitizer now rejects any document
whose root is not an svg element in the SVG namespace, and it compares
the scheme of an url the way the browser does, so a scheme split with
a tab or a line break no longer slips through the javascript: filter.
The attributes are written sorted by name, because the parser hands
them over in the reverse order of the document and the output changed
on every sanitization.
The profile picture of an identity provider is not stored either when
it is an SVG: the avatar is always rendered as an image, so an SVG
would only add the risk of keeping live markup in the storage. The
dimensions of an SVG are now read from its local name, so an SVG with
a namespace prefix can be uploaded like any other.
The logo had no size limit and the sanitizer reads the whole file, so
it now honors the media max file size.
The output shape changes with it: no line breaks, double quotes, and
the single quote of an attribute value escaped as an entity. The hash
of every stored SVG object changes with it.
The media tests use cuerdas.core instead of clojure.string, as the rest
of the codebase does.
Relates to GHSA-qwhj-rchj-58mg
AI-assisted-by: minimax-m3.1-flash
* 🐛 Drop non-SVG namespace subtrees in the SVG sanitizer
The sanitizer only checked the namespace of the root, so a child could
rebind xmlns to XHTML and smuggle live HTML (iframe/srcdoc/meta) past the
blocklist. The serializer escapes attribute values, but the browser decodes
them again, so srcdoc stays live.
Track the namespace bindings through the recursion and drop any element
that is not in the SVG namespace, with its subtree. The root is validated
up front with the same model, so a document that is not an svg document is
rejected with an honest hint instead of a misleading one.
The default namespace is stored under a namespaced key, because a prefix
literally named `default` collided with the internal key and re-opened the
same hole through a prefixed binding. The attribute filter now keeps
`xmlns`/`xmlns:*` declarations, which are not event handlers, so a prefix
named like one does not end up undeclared in the output.
The blocklist also drops HTML-only elements written in the svg namespace
(iframe, meta, embed, link, base).
Add regression tests for the child rebinding, for the prefixed variants,
for the `default` prefix, for the root local name, for the
create-file-media-object-from-url path (mocked download), and for the
HTML-only tags. Assert double-quoted attributes in the clean content test.
Relates to GHSA-qwhj-rchj-58mg
AI-assisted-by: longcat-2.5-free
* ✨ Strictly validate uploads against file bytes on disk
Sniff the real content type of every upload with simplemagic magic
bytes instead of trusting the client-supplied mtype header. Declared
types outside the allowlist keep the not-allowed code; allowed
declarations whose bytes differ are rejected as mismatch. Covers SVG
by probing its root tag over leading bytes (magic never matches XML
text), TTF/OTF by magic name and WOFF/WOFF2 by four-cc.
Read the real file size from disk instead of trusting the declared
:size. Files over the limit are rejected even when the declared size
lies downwards, and small files pass even when it lies upwards.
Run the path check first everywhere so forged server paths keep the
params-validation code. Recheck from-url downloads after the write,
with a declared-only fast fail at header time. Thread the assembled
path through font validation.
Refresh the affected tests around real and sparse fixture files,
including a PNG-as-font chunked-upload rejection case.
Closes#12796
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Accept BOM-prefixed SVG uploads and cover WOFF/WOFF2/OTF/WEBP
Address review comments on #12801:
- SVG files saved with a UTF-8 BOM were rejected as
:media-type-mismatch: read-head kept U+FEFF and the <svg probe
never ran. A leading BOM is now stripped before the probe, and a
generic XML sniff (application/xml, text/xml) is treated as
"no magic evidence" for declared image/svg+xml, so the root-tag
probe decides whether the file opens with an <svg root.
- sniff-match?/svg-file? docstrings claimed the magic database never
matches SVG; simplemagic 1.17 does match an SVG with an XML prolog
as image/svg+xml. Reworded to match reality.
- Added matching-pass tests for WOFF, WOFF2, OTF and WEBP (they hit
the read-four-cc, magic-name and direct magic branches) and three
BOM SVG regression cases.
AI-assisted-by: inkling-2-small-free
* 🐛 Keep drop shadows out from under translucent shapes at every zoom
Cut the shape out of its drop shadow, as CSS box-shadow does, and stop
large shadows from being cut off at high zoom.
AI-assisted-by: claude-opus-5-5
* 🐛 Keep drop shadows out from under translucent shapes in SVG and PDF
Match the canvas: clip the shape out of its drop shadow on export.
AI-assisted-by: claude-opus-5-5
send-template drops nil values before enqueuing the mail job, but the
invite-to-organization schema required the :user-name key. Inviting an
email with no Penpot account left :user-name nil, the key was dropped
and the job failed its schema check, so the invitation email was never
sent. :user-name is now optional in invite-to-organization and in
renewal-notice, which has the same shape.
New test renders both templates through the queue with a nil
:user-name.
AI-assisted-by: claude-opus-5-5
send-template drops nil values before enqueuing the mail job, but the
invite-to-organization schema required the :user-name key. Inviting an
email with no Penpot account left :user-name nil, the key was dropped
and the job failed its schema check, so the invitation email was never
sent. :user-name is now optional in invite-to-organization and in
renewal-notice, which has the same shape.
New test renders both templates through the queue with a nil
:user-name.
AI-assisted-by: claude-opus-5-5
The Playwright config gives the retrying expects 40 seconds on CI and 5
seconds elsewhere, but the default and ds projects set their own expect
block for the screenshot tolerance, and a project block replaces the
top-level one instead of merging with it. On CI those projects have run
with Playwright's 5 second default.
That is too short for the first dashboard load on a busy runner, where the
dashboard module is fetched lazily: specs that wait for the dashboard
heading time out and pass on the retry (flaky), or fail after the
retries. In the shard of one recent run that was 7 flaky and 4 failed
specs out of 112, and develop itself failed the same families of specs on
several runs the same day.
Name the timeout once and repeat it in both projects. A probe config that
imports this one and polls a condition that never holds reports 40000 ms
for both projects with this change, and 5000 ms without it. The
render-wasm project already repeats its own value.
AI-assisted-by: claude-opus-5-5
AI-assisted-by: claude-sonnet-5-5
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
team-container* called mf/deref only after returning early for a team id
that is not a uuid. When the same component rendered once without a team
id and then with one, the number of hooks changed between the two renders
and React threw "Rendered more hooks than during the previous render"
(error #310), which sent the user to the internal error page. The reverse
order throws "Rendered fewer hooks than expected".
Call the hook on every render and keep the uuid check in the condition
that builds the providers.
Two tests mount the component in jsdom and render it with a team id that
arrives and with one that leaves; both fail without this change.
Closes#12728
AI-assisted-by: claude-opus-5-5
AI-assisted-by: claude-sonnet-5-5
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
When components whose name matches the variant container are moved into
it, removing the container name leaves an empty value. Combining several
components named the same produced "Value 1" for the first variant and
empty values for the rest, so variants shared the same combination.
Assign the first unused "Value N" to the last property when all computed
values are blank, tracking values given within the same batch.
Combine moves the remaining mains into the container one at a time, in
the combine order, so the generated values follow that order instead of
the layer order.
Closes#12878
"Add to an organization" in the team settings opens a modal but was an
<a> without href: the keyboard could not reach it. It is now a
<button type="button"> that keeps its text-link look (accent color,
medium weight, underline on hover, the same box), so Tab reaches it and
Enter or Space opens the modal.
New visual test "Shows the Add to an organization link", with fixtures
for a team in no organization and an organization the user belongs to:
0 px of difference at rest and on hover, and 0 px on the whole settings
page.
AI-assisted-by: claude-opus-5-5
* ⚡ Keep the translation flag on undo and redo of a move
Previously undos and redos always forced a component sync + thumbnail
rerender for every component touched even if the underlying commits were just
translations. This propagates the translation flag to undos and redos and
propagates it to commit-changes. An entry merged from several undos/redos is
a translation only if all of its undos/redos are translations.
This reduces a UI freeze when undoing a move of a board with 453 components
from 2.42s -> 0.22s.
AI-assisted-by: claude-sonnet-5-5
* :translation? -> :translation
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* ⚡ Send objects-map pages to the worker without a second encode
When switching between pages on a large file e.g Material Design Icons, there's
some UI freeze from :index/initialize encoding object map shapes to send to the
worker. An objects map already stores the encoded JSON for each shape, and
`encode-str` was wrapping those strings in a second layer of JSON on the main
thread.
We can just transmit the already encoded JSON in a js/Map alongisde the
payload, similar to :blob, so structured clone just copies the strings verbaitm
without re-encoding. To do this this PR adds two functions to ObjectsMap that
converts to and from a js/Map.
The "Main components" page of the Material Design Icons has ~30k objects, so
this reduces the freeze when switching pages from 306ms -> 189ms (1.63x) on a
release build.
AI-assisted-by: claude-sonnet-5-5
* Split property tests out into new deftest
---------
Signed-off-by: Luke Lau <luke_lau@icloud.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Cache the transit reader when decoding strings
On the cljs side, we create a new transit reader on every call to decode-str.
This turns out to have a fair bit overhead in both transit-cljs and transit-js,
since it has to merge the handlers, call clj->js, allocate a new ReadCache,
etc.
By reusing it the measured decode time goes from ~58us -> ~41us, so about 1.4x
faster. This particularly helps for some operations like whenever a component
is edited and every object needs to be decoded, see
https://github.com/penpot/penpot/issues/9344, although they could probably be
made more efficient in other ways.
The reader usually clears its read cache after a successful read, but whenever
it throws it hangs around. So in this case we need to reset the cache
ourselves, otherwise the next read will be from a clobbered cache.
The tests were written by Claude Sonnet 5.5, and should cover the clobbering
behaviour above.
Signed-off-by: Luke Lau <luke@igalia.com>
* Keep one transit reader per type and rebuild it on add-handlers!
---------
Signed-off-by: Luke Lau <luke@igalia.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The client calls the duplicate-file command in two places, the dashboard and in
the plugin API. In both places the :data is dropped via `(dissoc file :data)`,
so the client never uses that field.
However the file data takes a while to send over the wire and decode. When
duplicating penpot-app there's a roughly 1s freeze whenever the response is
returned from the server with the file data. We can avoid it by dropping the
data from the response.
I also did a quick check and neither the MCP server nor any public client of
the API on GitHub seem to use the :data field.
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* 🔧 Bump Rust version of the devenv image to 1.99
* 🔧 Run the pinned Rust setup from the lint and format scripts
./build and ./test switch to the Rust version in render-wasm/_build_env
through setup, but ./lint did not, and formatting called cargo fmt
directly. In CI both ran on the devenv image's default toolchain.
Make ./lint run setup and add a ./fmt script that does the same, so all
render-wasm entry points use the pinned version. CI and scripts/ci now
call ./fmt instead of cargo fmt.
AI-assisted-by: claude-opus-5-5
Populate the 2.20.0 release notes from the milestone: 19 bugs fixed
and 31 new features & enhancements, classified by issue type with the
community contribution attributions and the merged PR references. No
💥 or 🚀 sections: no issue carries the breaking change or
release highlight labels. No GHSA entries: no published advisory lists
2.20.0 as patched version.
AI-assisted-by: inkling-2-small-free
The memories claimed link-issue does not close an issue
retroactively when its PR is already merged. Adding 'Closes #12896'
to merged PR #12067 and running link-issue closed the issue
immediately instead (observed 2026-11-09). Update the
mem:workflow/creating-issues, mem:workflow/creating-prs and mem:scripts/gh
entries to describe the observed behavior.
AI-assisted-by: inkling-2-small-free
Add the missing 2.19.0 entry for #12896: upload endpoints accepted
non-multipart upload paths and validation error bodies arrived
truncated. Fixed by #12067. The issue did not exist when the
changelog section was last generated.
AI-assisted-by: inkling-2-small-free
* 🐛 Return the full profile from the LDAP invitation branch
The invitation branch responded with only {:invitation-token token},
so the session start received a map without id or auth-backend. With
the new auth-origin vocabulary that map fails validation and the login
breaks in development and tests; in production the event would be
emitted without a channel.
Return the profile plus the token, like the regular branch, and cover
both branches with RPC-level tests.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Mint an auth token in the OIDC callback with the invitation inside
The callback used the invitation JWE as the session token, so the
frontend landed directly on token verification and never ran the
regular session start: no signin event, no teams, no host hook.
Always mint an :auth token and carry the invitation in its claims.
Token verification returns the claims verbatim, so login-from-token
attaches the invitation to the profile and logged-in does the rest,
the same pattern the verify-email token already uses.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* ✨ Keep the session origin in the telemetry shadow rows
The anonymisation filter drops every string unless it is on a short
allowlist. Add source and activation next to lang and auth-backend so
the shadow copy keeps them; the email and the full name are still
dropped and the email domain is still derived from the email.
The filter keeps its two bindings: the transducer discards everything
that is not a UUID, a boolean or a number, so the allowlist is applied
on the filtered map, never on the raw props.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* ✨ Describe the signup outcome with activation, not with action
The three success branches of register-profile labelled a new account
as "login", "email-verification" or "accept-invitation", so the event
contradicted its own name and the value only lived in context, where
the analytics consumers never read it.
Add activation to the event props and drop action from those branches:
the name already says it is a signup, and a constant action would only
add noise. The retry branches keep their action in context because
there it distinguishes four different outcomes.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* ✨ Describe the session origin in the signin event
The event carried the profile but never said why the session started:
the only field that tried was frontend metadata that never leaves the
browser, and several flows filled it with the wrong value.
Add a small closed vocabulary (source, auth-backend, activation,
from-invitation) and compose it from the profile when signin is
emitted. The assertion fails in development and tests and is elided in
the release build, so a bad value cannot break a real login.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* ✨ Emit the session origin from every login entry point
The four emitters stamped a free-form string as metadata, so the
session event could not rely on it: two flows that were signups wrote
the same value as a login.
Route them through origin-meta: password and LDAP logins declare
source login, register declares source register with activation
immediate, and login-from-token derives both from the token issuer:
:auth for a login and :verify-email for a signup that needed the
email confirmation.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Send the invitation signups through the regular session start
Registering from a team invitation link navigated by hand to token
verification, so the session start never ran: no signin event, no
teams, no host hook. The same navigation was copied in four places.
Teach login-from-register to carry an optional invitation token,
extract the profile decoration into with-register-origin, and call it
from the four sites instead of the hand-written navigation.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Resolve the invitation without waiting for the teams
Registering from an invitation link now goes through the session
start, and that start only navigates after the teams request
completes. fetch-teams has no error handling, so a network failure
there would leave the invitation unaccepted and the user on the signup
screen with a session already minted.
Move the invitation navigation out of the teams path: it emits as soon
as the profile carries a token, and the teams path emits nothing for
an invited profile so it cannot move the user away from the token
verification screen.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* 📚 Document the session origin vocabulary and the invitation flow
Add a focused memory with the four fields, where each value comes from,
the table of flows, the LDAP exception and the invitation path through
logged-in, and link it from the frontend routing.
Update the audit-log memory: source and activation are now on the
telemetry allowlist, activation lives in props and action only exists
on the retry rows.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
* 📎 Cover the invitation session start and fix the auth memories
The invitation flows and the token decoration in login-from-token were
only covered by helper tests, so removing the guard that keeps the
teams path from navigating, or the token attach, broke the behaviour
silently.
Run the events through ptk/watch with a teams-fetched event as input:
an invited profile navigates to the token verification screen and
never to the dashboard, and the signin keeps from-invitation. Add the
login-from-token cases with and without the token, and fix the memory
statements about action, the flow table and from-invitation.
Closes#12849
AI-assisted-by: deepseek-v4.1-flash
Ignore imported library relations that point outside the
package or self-link a file on the v3 import path. Delete
leftover cross-team and self relations from file_library_rel
and their matching file_library_sync rows with migration
0158, sync rows first.
Move the get-files-rels SQL to a var, without behaviour change.
Cover the write-side invariants with state-based tests:
tampered manifests, package containment, self links and the
migration cleanup.
AI-assisted-by: deepseek-v4.1-flash
AI-assisted-by: muse-spark-1.3-contributor
(cherry picked from commit 0b8a428d6b28c35601793f8d05570dae614a2fd5)