24696 Commits

Author SHA1 Message Date
Marina López
df0092fc60
🐛 Enforce sso before creating organization teams (#12980) 2026-11-12 08:35:32 +01:00
Pablo Alba
34f0d12b99
🐛 Ignore float noise when syncing moved library components (#12976) 2026-11-11 17:09:33 +01:00
andrés gonzález
0d05cfaf5e
✨ Add the file to plugin and MCP audit events (#12972)
* ✨ Add the file and MCP session to plugin audit events

Events sent by plugins and the MCP plugin only carried their origin, so
the multi-file and dashboard MCP workflows could not be measured, nor
duplicated events from several open tabs spotted.

The context of se/event and se/add-event now carries the file-id of the
file the action ran in (absent on the dashboard) and, for the MCP, the
mcp-session-id of the tab connection that ran it.

Closes #12902

AI-assisted-by: claude-opus-5-5

* ♻️ Drop the MCP session id from plugin audit events

Every audit event already carries the session-id of the tab in its
context, and the MCP session id is only a hash of it, so it adds
nothing to group events by tab.

AI-assisted-by: claude-opus-5-5

* 🐛 Use a reified event in the system events test

ptk/data-event does not implement IWithMeta, so add-event failed on it.
Plugin actions emit reified events, which carry metadata.

AI-assisted-by: claude-opus-5-5
2026-11-11 16:18:15 +01:00
andrés gonzález
ca616a39ed
🐛 Send the plugin origin with penpotMgmt dashboard actions (#12971)
Dashboard actions made by a plugin or the MCP through penpotMgmt reached
the backend audit log without event origin, so they looked like actions
made by hand in the dashboard. The backend reads the origin from the
x-event-origin header, which rp/cmd! only sends when the params carry
::ev/origin in their meta.

Tag the params of every penpotMgmt mutation with the same meta that
se/add-event sets (mcp or plugin). The rename, share and team update
events now keep that meta on the RPC params, so setters send it too, and
set-file-variants-shared takes the caller origin instead of a fixed
dashboard one.

Closes #12901

AI-assisted-by: claude-opus-5-5
2026-11-11 16:02:29 +01:00
Pablo Alba
bb672a3723 🐛 Block grid track duplication and paste in component copies 2026-11-11 15:32:10 +01:00
Pablo Alba
3eebe92606 🐛 Keep the swap slot when duplicating a swapped nested copy 2026-11-11 15:32:10 +01:00
andrés gonzález
54b1eb5d5f
✨ Add shape count to the apply-tokens audit event (#12970)
The apply-tokens event fires once per apply call, so a plugin or MCP
script that loops over shapes sends one event per shape while the UI
sends one per selection. Add a shape-count prop with the number of
shapes the token was applied to, so analytics can compare origins in
the same unit.

Closes #12900

AI-assisted-by: claude-opus-5-5
2026-11-11 14:43:11 +01:00
Andrey Antukh
bcac14a4a8
🐛 Skip circular library links on binfile import (#12062)
* 🐛 Skip circular library links on binfile import

File import recreated circular library references that the
link-file-to-library command already rejects. The v3 importer
now skips manifest relations that would close a cycle, or link
a file to itself, and logs each skipped link. Closes #12052.

AI-assisted-by: muse-spark-1.3-contributor-free

🐛 Address review findings on circular import guard

F1 extracts the cycle check into bfc/library-link-status and reuses
it in link-file-to-library and v3 import, keeping UI errors intact.

F3 orders get-files-rels and manifest relations so the first edge
wins deterministically, and documents it.

F2 adds a three-node transitive cycle test; F4 asserts file_library_sync
has no orphan rows for skipped links and matches rels otherwise.

AI-assisted-by: muse-spark-1.3-contributor

🐛 Pass team-id in binfile v3 circular library tests

The four circular library relation tests (import-skips-circular,
import-skips-transitive-cycle, import-skips-self-referencing,
import-keeps-acyclic) called v3/import-files! without ::bfc/team-id.
resolve-and-link-libraries asserts (uuid? team-id), so with
enable-backend-asserts the tests errored instead of running.

Add (assoc ::bfc/team-id (:default-team-id profile)) to each test,
mirroring the production RPC import path and the earlier fix in
a36193e54b for the other binfile v3 import tests.

Verified: 1338 tests, 6910 assertions, 0 failures.

AI-assisted-by: mistral-large-4

* 🐛 Guard the v1 import and restore the RPC check order

The version 1 importer recreated the circular library references that
the version 3 importer already skips, and a user reaches it through
`import-binfile` and the import job. It now asks
`bfc/library-link-status` before inserting each relation, so a self
link or an edge that would close a cycle is skipped and logged, and it
processes the relations in sorted order like v3 does. Closes #12052.

The version 2 importer stays out on purpose: only the developer REPL
reaches it, and a note in the code records the gap.

`link-file-to-library` keeps the shared helper but restores the order
of the base commit: the self link guard, then the edition permission
and team ownership checks, and only then the cycle check. A caller
without permission gets `not-found` again instead of the circular
error, and a rejected request no longer runs the recursive library
query first.

Tests: a v1 import that recreates a cycle, the self link error of the
command, and the permission check running before the cycle check.

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Tolerate repeated file entries in the v1 binfile import

The v1 exporter unioned the requested files with their libraries
without deduplicating, so a package exported from a file inside a
library cycle (see #12052) lists that file twice in the metadata. The
v1 import created one file per entry, so the second insert hit the
primary key of `file` and aborted the whole import with "file already
exists".

The importer now reads the pair of objects of every entry to keep the
stream in sync but imports the first occurrence only, warns about the
repeats, and answers with one id per imported file.

The v1 export keeps its behavior: it has no caller in the product, and
only the tests and the developer REPL use it.

Tests: a package with a repeated entry, built with the v1 export from
a cyclic library pair, imports the file once and leaves no duplicated
rows.

AI-assisted-by: deepseek-v4.1-flash
2026-11-11 13:02:57 +01:00
Alonso Torres
1599e096da
🐛 Fix MCP session discovery for plugins connected from the dashboard (#12865)
* 🐛 Fix MCP session discovery for plugins connected from the dashboard

With Redis-backed routing, execute_code without a sessionId timed out
with SessionDiscoveryError when the plugin ran on the dashboard. The
discovery reply schema required a string fileId and fileName, so replies
listing a session with no open file were dropped, and discovery waited
for its timeout.

Share one session schema between the plugin handshake and discovery
replies. It accepts a null file and keeps workspaceState. An unreadable
reply now fails discovery at once with an error that names the problem
and says a retry will not help. Timeout and Redis failures state their
reason.

Closes #12860

* 🐛 Stop blaming server load when MCP discovery fails on shutdown

Closing the dispatcher rejected pending discoveries with the default advice,
which blamed temporary server load. Shutdown now gives its own advice.

The dispatcher tests now cover each discovery failure path: an unreadable
reply alongside a valid one rejects with InvalidDiscoveryResponseError and
does not dispatch, a late unreadable reply leaves a settled discovery
unchanged, and timeout, Redis failure and shutdown each state their reason.
2026-11-11 12:10:34 +01:00
Alonso Torres
adda9ad908
✨ Restrict global plugins to MCP and localhost (#12949)
Plugins with global scope or a management permission (allow:global,
manage:projects, manage:teams, manage:delete) keep running outside a file
and can drive the management API. That feature is still in progress, so
only the bundled MCP plugin and plugins served from localhost, 127.0.0.1
or [::1] get it. The host check ignores letter case and a trailing dot.

The check runs at three points:

- parse-manifest throws global-not-allowed, so the plugin manager and the
  dashboard install link refuse remote global plugins with a specific
  message. Opening an installed plugin fetches its manifest again, so
  plugins stored before this change are refused too.
- start-plugin! and load-plugin! refuse to run such a plugin and warn the
  user; this also covers bundled plugins without a URL.
- has-permission? withholds the global permissions from stored remote
  plugins, so the management API rejects them.

AI-assisted-by: claude-opus-5-5
2026-11-11 10:28:11 +01:00
andrés gonzález
37f4556b0b
📚 Document MCP sign-in with OAuth (#12950)
Explain how MCP clients with OAuth support connect to the remote MCP
server with a URL that has no key: the user signs in to Penpot, allows
access, and the client receives the MCP key. Keep the key-in-URL path
for clients and instances without sign-in.

Describe the approval page, what allowing access changes, and how
regenerating or expired keys affect signed-in clients. Document
PENPOT_MCP_AUTH_URI for self-hosted instances.

Relates to #12034

AI-assisted-by: claude-opus-5-5
2026-11-11 09:54:27 +01:00
Alonso Torres
f89879cca1
🐛 Fix plugin component lookups for missing and deleted components (#12755)
* 🐛 Fix plugin component lookups for missing and deleted components

Shape.component() threw "Assert failed: (uuid? id)" on a copy whose
library is not available, because the head lookup returns a pair with a
nil component and the proxy was built anyway. It now returns null.

Shape.componentRoot() built its proxy with the component's file and main
page, so a copy on another page or file than its main got a shape that
pointed at nothing, and a copy of a missing library threw. It now
resolves the root on the shape's own file and page, like componentHead().

LibraryComponent.mainInstance() returned a proxy for the root kept in a
deleted component's objects, which is on no page, so every field read
empty and parent threw. It now returns null for a deleted component.

* 📚 Fix the variant example in the MCP instructions

The createVariantContainer example called penpot.currentPage.findAllShapes
and Shape.isMainComponent, which do not exist, so running it as written
failed. Use penpotUtils.findShapes with isComponentMainInstance instead.
2026-11-11 09:39:04 +01:00
Andrey Antukh
34c702bc80
✨ Add table partitioning for file-change (#12417)
* ✨ Partition file_change by file_id

Partition `file_change` by `HASH (file_id)` in 16 partitions, with the
same bounds as `file_data`, so a join between them is resolved partition
by partition. The primary key becomes `(file_id, id)`, which a
partitioned table needs, and every access filters by `file_id`.

The new table carries a `kind` column (1 snapshot, 2 revision) as a
reserve: the queries still tell them apart by the label, and `kind`
takes over when they are rewritten. The payload of a snapshot stays in
`file_data`; the `data` column is kept for reading old `legacy-db`
payloads.

The four snapshot commands require `file-id`, because it prunes the
partition. The objects GC locks and deletes by the pair, in batches. The
old table is kept as `file_change_legacy` as the way back.

The automatic migration refuses to run above 10 GiB or 2M rows and
points to the runbook; big instances partition online with that runbook,
which copies by id, follows the `updated_at` watermark in delta passes
and checks that no row of the source is missing from the target.

Closes #12416

AI-assisted-by: deepseek-v4.1-flash

* 🐛 Address the review of the file_change partition

The production runbook promised a rollback it did not have: the section
was a sketch, and its statements could write to the table about to be
dropped. The rollback is removed, and so is every comment that described
it. `file_change_legacy` stays as a frozen copy of the previous layout.

The cutover check now aborts the transaction when a row of the old table
is missing in the new one, instead of leaving the decision to the
operator.

The five secondary indexes of the new table are built after the last
delta pass: the bulk and the delta passes only need the primary key, so
the batches no longer maintain them.

The copy of 0158 is now replayed in a test against the pre-0158 shape
with a labelled and an unlabelled row, plus an empty instance that must
leave no legacy table behind. The updated_at assertion of the snapshot
mutations is bounded by the test clock, and the repair revision stops
writing the `features` of the file, like the normal save.

AI-assisted-by: deepseek-v4.1-flash

* 📎 Fix linter issues

* 🐛 Fix stale helper call in rpc_file_snapshot_test

The suite does not load: `th/run-pending-tasks!` does not exist in
backend-tests.helpers. The pending jobs runner is `run-pending-jobs`
(no arguments), the name every other call site of the suite uses.

AI-assisted-by: inkling-2-small-free

* 📚 Align the partition change with the 2.20.0 milestone

2.19.0 is already shipped; this work targets 2.20.0. The operations
file of the partition procedure carries the 2.20.0 prefix, the
references that point at it (the migration, the runbook table and the
memory) follow, and the RPC doc/changes tags of the snapshot commands
record 2.20 as the release that makes `file-id` required.

AI-assisted-by: inkling-2-small-free

* 🐛 Fix review findings on the partition rebase

The 0157 legacy test was missed by the rebase and inserted file_change
rows without the new mandatory `kind` column, so the backend suite fails
against the migrated schema. The inserts now carry `:kind`
(snapshot-kind for the labelled rows, revision-kind for the rest).

The post-cutover verification block of the runbook terminates the
first query with a semicolon so the block pastes into psql without a
syntax error.

The guard message of the migration names the step
(0158-partition-file-change) instead of "0158", ambiguous since two
migrations share that number.

Closes #12416

AI-assisted-by: inkling-2-small-free

* 🐛 Fix the stale label in the snapshot restore test

The rename block of snapshots-crud runs before the restore block,
so the restored version is already named "label1-updated" when the
restore publishes its message. The command names the version by the
label the row has at that moment, not by the one it had when it was
created, so the test was asserting a stale value.

Assert the current label, and say why in the comment.

AI-assisted-by: deepseek-v4.1-flash

* ♻️ Split the version event tests into one test per command

The workspace version events and the plugin proxy send the file-id
of the open file on every snapshot command, because the command
needs it to prune the partition of file_change. Both tests checked
that with a doseq and t/testing blocks, so a failure named the whole
group and not the command that broke.

Give each command its own test. The workspace ones share a helper
that runs the event and returns the command and params it sends; the
plugin ones keep their own mock of rp/cmd!.

AI-assisted-by: deepseek-v4.1-flash
2026-11-10 23:27:17 +01:00
Filip Sajdak
58b349fb77 🔧 Keep the CI expect timeout in the default and ds e2e projects (#12922)
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>
2026-11-10 23:00:14 +01:00
Andrey Antukh
8172059b2e Merge branch 'staging-2.19.x' into staging 2026-11-10 19:53:23 +01:00
Andrey Antukh
5e5722dfe7 📚 Update changelog 2.19.1 2026-11-10 19:24:39 +01:00
Andrey Antukh
a58ace4df4 Merge branch 'staging-2.19.x' 2026-11-10 19:18:41 +01:00
Andrey Antukh
d5c4bfb234 ✨ Add security fixes section to the changelog flow
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
2026-11-10 18:18:00 +00:00
Andrey Antukh
e4fcc663c0 Merge remote-tracking branch 'origin/staging-2.19.x' into staging 2026-11-10 18:09:07 +01:00
Andrey Antukh
88b09efd87 📚 Note breaking changes must not be hard-wrapped
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
2026-11-10 16:58:21 +00:00
Yamila Moreno
a3f50887b3 📚 Improve 2.19 documentation regarding admin-console
Signed-off-by: Yamila Moreno <yamila.moreno@kaleidos.net>
2026-11-10 16:13:06 +01:00
Andrey Antukh
1db1487c7f
🐛 Require thread access when marking threads as read (#12894)
* 🐛 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
2026-11-10 15:54:11 +01:00
Andrey Antukh
8510daef52
🐛 Restrict the team invitation token and list to admins (#12892)
* 🐛 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
2026-11-10 15:53:43 +01:00
Andrey Antukh
0420db1ca9
🐛 Apply the share-link page scope to the comment queries and writes (#12888)
* 🐛 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
2026-11-10 15:53:07 +01:00
Andrey Antukh
4874da450f
🐛 Escape SVG values and sanitize every SVG upload (#12887)
* 🐛 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
2026-11-10 15:52:28 +01:00
Juanfran
e1f924cf00 🐛 Send organization invitations to unregistered emails (#12937)
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
2026-11-10 14:12:27 +01:00
Marina López
4e8f05171e
🐛 Forward attribute deletion events to admin console (#12932) 2026-11-10 13:47:13 +01:00
Alonso Torres
79d4936cae
⬆️ Release plugins and mcp (#12934)
* ⬆️ Updated plugins release to 1.6.0

* ⬆️ Updated mcp release to 2.19.1
2026-11-10 13:44:51 +01:00
Andrés Moya
10b08d5224 🐛 Improve sync of layout item attributes in components (#12851)
* 🐛 Preserve layout item touched groups when switching variant

* 🔧 Add systematic tests to check layout items component sync

* 🔧 Add small updates after comments

* 🔧 Add tests for partial overrides
2026-11-10 13:41:54 +01:00
Alonso Torres
4fb0ea9eee
🐛 Give unique values to same-name components combined as variants (#12881)
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
2026-11-10 09:16:08 +01:00
Andrey Antukh
4b5dd51b39 📚 Add 2.20.0 changelog section
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
2026-11-09 11:19:50 +00:00
Andrey Antukh
4c31e90665 Merge remote-tracking branch 'origin/develop' into staging 2.20.0-RC1 2026-11-09 11:15:38 +01:00
Hosted Weblate
9034e707bf
🌐 Merge branch 'origin/develop' into Weblate. 2026-11-09 11:09:57 +01:00
Alexis Morin
968edc6a55
🌐 Add translations for: French (Canada)
Currently translated at 96.7% (2285 of 2361 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr_CA/
2026-11-09 11:07:01 +01:00
AntonPalmqvist
a2dfcb8669
🌐 Add translations for: Swedish
Currently translated at 95.9% (2266 of 2361 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/sv/
2026-11-09 11:06:59 +01:00
Andrey Antukh
f66e5afa70 📚 Correct link-issue closing behavior in workflow memories
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
2.19.0-RC7 2.19.0
2026-11-09 09:56:57 +00:00
Andrey Antukh
212037f368 📚 Add upload path hardening entry to 2.19.0 changelog
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
2026-11-09 09:56:27 +00:00
Andrey Antukh
1df2941576 Merge remote-tracking branch 'origin/staging' into develop 2026-11-09 10:42:28 +01:00
Andrey Antukh
b766e88ed8 Merge remote-tracking branch 'origin/main-2.18.x' into staging 2026-11-09 10:31:58 +01:00
Andrey Antukh
f90ef83da0 📚 Update changelog 2.18.7 2026-11-09 10:28:54 +01:00
Andrey Antukh
341e3dfa65 Merge remote-tracking branch 'origin/staging' into staging 2026-11-09 10:17:47 +01:00
Andrey Antukh
e86131cf16 Merge branch 'main-2.18.x' into staging 2026-11-09 10:17:33 +01:00
Andrey Antukh
0ea6d166f0
✨ Add a session origin model to auth events (#12856)
* 🐛 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
2026-11-09 10:11:54 +01:00
Andrey Antukh
9b1e18a478 🐛 Validate binfile library relations against the package (#12862)
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)
2026-11-09 08:46:19 +00:00
Alexis Morin
5b9bf3cbba
🌐 Add translations for: French (Canada)
Currently translated at 96.5% (2279 of 2361 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr_CA/
2026-11-08 10:51:26 +01:00
Danial Shirali
44a0cc7147
🌐 Add translations for: Persian
Currently translated at 94.5% (2233 of 2361 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fa/
2026-11-08 10:51:22 +01:00
penpot-renovate[bot]
904cdd9716
⬆️ Update DHI base images (#12814)
- dhi.io/debian-base trixie-debian13-dev@61025ce (docker/images/Dockerfile.backend)
- dhi.io/debian-base trixie-debian13-dev@61025ce (docker/images/Dockerfile.backend)
- dhi.io/debian-base trixie-debian13-dev@61025ce (docker/imagemagick/Dockerfile)
- dhi.io/debian-base trixie-debian13-dev@61025ce (docker/devenv/Dockerfile)
- dhi.io/nginx 1.31.8-alpine3.24@34c5f1f (docker/images/Dockerfile.storybook)
- dhi.io/nginx 1.31.8-debian13-dev@f88f28c (docker/images/Dockerfile.frontend)
- dhi.io/node 24.22.2-debian13-dev@24214dd (docker/images/Dockerfile.media-processor)
- dhi.io/node 24.22.2-debian13-dev@24214dd (docker/images/Dockerfile.mcp)
- dhi.io/node 24.22.2-debian13-dev@24214dd (docker/images/Dockerfile.exporter)

Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com>
2026-11-07 22:53:06 +01:00
penpot-renovate[bot]
75b3da5290
⬆️ Update dependency pnpm to v12.18.0 (#12876)
- pnpm 12.18.0 (docker/images/Dockerfile.media-processor)
- pnpm 12.18.0 (docker/images/Dockerfile.mcp)
- pnpm 12.18.0 (docker/images/Dockerfile.exporter)
- pnpm 12.18.0 (docker/devenv/Dockerfile)
- pnpm 12.18.0 (render-wasm/package.json)
- pnpm 12.18.0 (plugins/package.json)
- pnpm 12.18.0 (plugins/libs/plugins-styles/package.json)
- pnpm 12.18.0 (plugins/libs/plugins-runtime/package.json)
- pnpm 12.18.0 (plugins/libs/plugin-types/package.json)
- pnpm 12.18.0 (plugins/apps/table-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/rename-layers-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/poc-tokens-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/poc-state-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/plugin-api-test-suite/package.json)
- pnpm 12.18.0 (plugins/apps/lorem-ipsum-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/icons-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/example-styles/package.json)
- pnpm 12.18.0 (plugins/apps/e2e/package.json)
- pnpm 12.18.0 (plugins/apps/create-palette-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/contrast-plugin/package.json)
- pnpm 12.18.0 (plugins/apps/composable-test-suite/package.json)
- pnpm 12.18.0 (plugins/apps/colors-to-tokens-plugin/package.json)
- pnpm 12.18.0 (package.json)
- pnpm 12.18.0 (media-processor/package.json)
- pnpm 12.18.0 (mcp/packages/server/package.json)
- pnpm 12.18.0 (mcp/packages/plugin/package.json)
- pnpm 12.18.0 (mcp/packages/common/package.json)
- pnpm 12.18.0 (mcp/packages/browser-extension/package.json)
- pnpm 12.18.0 (mcp/package.json)
- pnpm 12.18.0 (library/package.json)
- pnpm 12.18.0 (frontend/text-editor/package.json)
- pnpm 12.18.0 (frontend/packages/ui/package.json)
- pnpm 12.18.0 (frontend/packages/mousetrap/package.json)
- pnpm 12.18.0 (frontend/packages/draft-js/package.json)
- pnpm 12.18.0 (frontend/package.json)
- pnpm 12.18.0 (exporter/package.json)
- pnpm 12.18.0 (docs/package.json)
- pnpm 12.18.0 (common/package.json)
- pnpm 12.18.0 (backend/package.json)

Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com>
2026-11-07 22:52:43 +01:00
Alexis Morin
dac470cc94
🌐 Add translations for: French (Canada)
Currently translated at 94.4% (2231 of 2361 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/fr_CA/
2026-11-07 08:51:35 +01:00
Stephan Paternotte
da8cdb734b
🌐 Add translations for: Dutch
Currently translated at 100.0% (2361 of 2361 strings)

Translation: Penpot/frontend
Translate-URL: https://hosted.weblate.org/projects/penpot/frontend/nl/
2026-11-07 08:51:32 +01:00