24724 Commits

Author SHA1 Message Date
Álvaro Tejero Cantero
a41feaaa19
♻️ Rename the overlay suites to the relation vocabulary
The three test namespaces still carried graph-overlay in their
names and files. They are backend-tests.relation-overlay-test,
-sync-test, and -console-test now, so the branch carries no old
holder name outside dated records and the storage literal.

AI-assisted-by: glm-5.3-flash
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-11-11 17:39:14 +01:00
Álvaro Tejero Cantero
50cc571c94
♻️ Rename app.graph.ingest to app.graph.export
The holder vocabulary names the persisted relation write an
export, so the batch tier takes app.graph.export, its file entry
point becomes export-file!, and the two on-connection helpers
follow inside the namespace. print-ingest! and the srepl
ingest-file-to-graph! keep their names, with their references
repointed; their own renames can ride a reviewer's ask.

AI-assisted-by: glm-5.3-flash
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-11-11 17:39:14 +01:00
Álvaro Tejero Cantero
c725d78d9a
💄 Rename the holder in two remaining comments
The datascript dependency's comment in frontend/deps.edn still
named the graph-overlay and the retired derived-index worker, and
the Ladybug-mirror set's docstring still named the graph-worker
branch. Both now use the holder names.

AI-assisted-by: glm-5.3-flash
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-11-11 17:39:14 +01:00
Álvaro Tejero Cantero
212dae792a
♻️ Rename app.graph.overlay to app.graph.relation-overlay
The holder vocabulary names a graph by scope and a holder by
location, scope, and form, so the overlay namespace takes the
holder's name: app.graph.relation-overlay, with its console,
queries, and sync children. Aliases keep their short forms.

schema-version keeps its "penpot-graph-overlay-2" literal: it is
a storage version, and renaming it would rekey stored artefacts
without a schema change.

AI-assisted-by: glm-5.3-flash
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-11-11 17:39:14 +01:00
Álvaro Tejero Cantero
1ccfd315a6
💄 Clear the overlay's lint warning and formatting drift
The cascade's verify gate lints backend, common, and frontend/src/bench,
and clj-kondo exits non-zero on a warning, so the redundant let in
apply-set-token-set held every rebuilt candidate of this branch back.
The two lets become one, with no change in behaviour. An unused pid
binding in graph-overlay-test gets its underscore.

cljfmt fix over the three files ./scripts/ci reported as formatted
incorrectly, so the backend and frontend fmt checks pass.

clj-kondo over the verify scope: 0 errors, 0 warnings. The overlay
suites: 56 tests, 788 assertions, 0 failures.

AI-assisted-by: claude-opus-5-5
2026-11-11 17:39:14 +01:00
Álvaro Tejero Cantero
0f258ae945
♻️ Keep the ingest allocator test the parity suite carried
develop added ingest-on-connection-requires-allocator to
graph_sync_parity_test.clj, beside the round trip it shares no fixture
with. This branch retires that file with app.graph.sync, while
app.graph.ingest stays as the batch-export tier, so the test moves to a
namespace of its own rather than leaving with the suite.

AI-assisted-by: claude-opus-5-5
2026-11-11 17:39:14 +01:00
Álvaro Tejero Cantero
0ed540f6ec
🐛 Classify the two token change types develop added
develop replaced :set-active-token-themes with :set-tokens-status, which
writes the active theme and set ids into :tokens-status, and added
:set-tokens-source, which points a file at an external tokens library.
the-classification-table-names-every-change-type fails by name on both,
which is what it is for.

Both are no-ops for the overlay. The builder indexes token sets and
tokens from the file's own :tokens-lib and stores no active status, and
it reads that lib whatever :tokens-source says, so neither change can
reach a datom a rebuild would write.

AI-assisted-by: claude-opus-5-5
2026-11-11 17:39:14 +01:00
Álvaro Tejero Cantero
c67ea2473e
🐛 Decide a move the way the document decides it
The move mirror re-pointed a parent edge for every shape a :mov-objects
change named, so the fold followed a move the document refused. Two
measured consequences: moving a shape into its own subtree left the fold
disagreeing with a rebuild, and moving a shape under its own descendant
closed a cycle in the fold's parent edges, after which the fold had not
returned eight seconds later with app.graph.overlay.sync/subtree-eids on
the stack.

The gate is all-or-nothing for the whole change, which is the half a
per-shape mirror cannot see: process-change :mov-objects proceeds only
when the change names a shape, the parent exists, and every named shape
passes its own is-valid-move?, so one invalid shape in a batch of ten
moves none of them. The mirror now asks the same question of the running
state, in the change's own order.

It asks rather than answers, because a predicate written again in the
applier would be a second definition of which moves are legal, and the
two would drift the first time Penpot changes a nesting rule. So
calculate-invalid-targets and the predicate move out of the change
method into valid-move? at the namespace level, taking the objects view
and the two flags, with the method's own call site delegating to them and
no behaviour changed. The objects view is what the answer takes rather
than a container: the decision walks the moved shape's subtree down
:shapes and the destination's chain up :parent-id, reading :component-id
and :shape-ref off every shape it meets, and the overlay holds all of
those, which is why the same predicate answers for an index.

subtree-eids gains the guard the cycle case needs, so a cyclic index
fails loudly instead of spinning: the walk tracks the shapes it has
entered and returns.

Six new tests, and each mutation of the repair fails by name rather than
in an unrelated test: dropping the gate fails seven assertions across the
three gate tests, weakening every? to some fails the three that pin the
change-level reading, asking the predicate about the wrong destination
fails the same seven, and removing the walk guard fails the two that pin
the cycle.

backend-tests.graph-overlay-sync-test: 29 tests, 228 assertions, 0
failures.

Entry: pp:graph:decide-a-move-the-way-the-document-does open -> done

AI-assisted-by: deepseek-flash
2026-11-11 17:39:14 +01:00
Álvaro Tejero Cantero
dfac8ba046
✨ Close the applier's coverage gaps and mirror a reorder
Child order lives in the document as each container's :shapes vector, and
the projection recorded it only as the global Euler intervals, so the
index had no attribute a consumer could read to learn that a shape is the
third child of its parent rather than the fourth. :shape/order is that
attribute now: a plain 0-based integer, dense within one parent, declared
in the schema beside :shape/id and written by the builder per shape from
the parent's vector, exactly as the intervals are derived from it. The
intervals keep their purpose, which is containment, and the ordinal does
not answer a containment query.

With an order in the index, :reorder-children stops being a skip.
process-change carries :parent-id and :shapes, so the new arm writes the
container's ordinals from that vector and retracts the ordinal of a child
the vector no longer holds. The classification table's row moves to
:covered, and the tier supports twenty-five of the thirty-nine change
types rather than twenty-four.

The applier's other gaps close beside it. The raw colour and typography
reference ids ride in the index beside the resolved refs, so an asset
added after the shape that named it repairs every shape that was waiting
for it. A token name written onto a taken name displaces the holder as
one retraction rather than refusing. Every declared shape attribute names
its writer, so an attribute cannot be added to the schema and to no
writer. And a batch that skipped or dropped a change marks the session
stale, so both read gates refuse rather than answer from an index that
disagrees with the document.

Measured on a 5,101-object page: 20,411 datoms before one insertion and
20,412 after, of which one is the new shape's ordinal and none touches a
shape already present, so an append rewrites no sibling while a deletion
rewrites the ordinals after it (9,997 of them for the hundredth child)
and a move pays the interval renumbering it already paid.

backend-tests.graph-overlay-sync-test: 23 tests, 203 assertions, 0
failures.

Entry: pp:graph:sibling-order-needs-an-authored-attribute open -> done
Entry: pp:graph:mirror-the-reorder-children-change open -> done
Entry: pp:graph:store-the-raw-asset-ref open -> done
Entry: pp:graph:mirror-the-token-name-displacements open -> done
Entry: pp:graph:pin-the-attribute-vocabulary open -> done
Entry: pp:graph:does-a-set-token-carry-its-id open -> answered
Entry: pp:graph:stale-index-gate open -> done
Entry: pp:graph:close-applier-coverage-gap open -> done

AI-assisted-by: deepseek-flash
2026-11-11 17:38:11 +01:00
Álvaro Tejero Cantero
2c16db6fae
🐛 Refuse a reference the projection never defined, and re-measure honestly
Review found two defects in the bulk build.

A reference may name a tempid the transaction never defines, because
ref-resolver answers with whatever ctf/find-ref-shape names and that
shape can sit in a container the builder skipped. The flattener resolved
such a target to nil, which puts a nil-valued reference in the index.
d/db-with refuses the whole transaction there, with :transact/syntax and
"Tempids used only as value", so the bulk path refuses it too and names
the defect: :projection/dangling-reference. A unit test pins both
refusals, and pins that the same transaction works on both paths once the
target is defined. The flattener is now tx->datoms, taking a
transaction, so the case is testable without a document that reaches it.

The timings in the docstring were the prototype's, taken on a JVM that
had served the session. Re-taken thirty seconds after boot, with the
first pass reported apart: a build is 800 ms cold and 471 to 513 ms warm
against 2,357 ms and 2,147 to 2,181 ms, so 4.2 times rather than 5.3.
The walk alone is 314 to 325 ms and steady from the first pass, which
puts the flatten and the load together at about 185 ms of a warm build.

Entity ids are an artefact of the interning order, and the docstring now
says so: deleting one leaf shape shifts every later id. Two builds of one
document are comparable by id and two builds of two documents are not,
which is a constraint on whatever compares them.

backend suite unchanged at 161 pre-existing failures, 826 tests and 5499
assertions against 824 and 5491 before.
2026-11-11 17:38:11 +01:00
Álvaro Tejero Cantero
f0913a93b6
⚡ Build the overlay bottom-up from datoms instead of transacting
The transact path spends its time resolving one tempid per entity, not
inserting datoms: the 2026-08-21 retrofit added 48 per cent more datoms
for no extra time. datascript's init-db skips that resolution and builds
the B-trees from sorted arrays, and build-tx already mints deterministic
string tempids from document ids, so assigning final entity ids is one
interning pass over the transaction.

On the 21,218-shape design system, measured back to back in one warm
JVM: 410 ms against 2,150 ms for the same 217,598 datoms. The graphs are
the same graph. Entity ids differ and carry no meaning, and the datom
sets are identical once every id is read back through the tempid that
minted it, which the new test asserts along with the stats and the edge
counts.

This is sound only while every build transform reads the document rather
than the half-built index, because init-db has no index to read. The
existing transforms all do: euler-numbering walks :objects, ref-resolver
goes through ctf/find-ref-shape, and the asset maps come from :data.

backend suite unchanged at 161 pre-existing failures (media and storage,
unrelated), 825 tests and 5495 assertions against 824 and 5491 before.
2026-11-11 17:38:10 +01:00
Álvaro Tejero Cantero
bf7295b847
🔧 Run the A/B bench at :advanced, now that the corruption is answered
The :simple pin existed because datascript's query layer was broken at
:advanced under this toolchain, which mirror:ds-advanced-corruption has
since answered: shadow-cljs never reads a library's own deps.cljs
externs declaration, so the build must name the file itself.

:ab-bench therefore runs :advanced with :externs
["datascript/externs.js"], and with :infer-externs :all to match the
datahike column, whose build needs it. The default :auto infers externs
for a project's own sources and not for a jar.

bench.core/-main now exits explicitly. Datahike keeps the node event
loop alive after the last reading, and the shared body must stay
identical between the two worktrees.

Entry: pp🪞graph-overlay-experiment
AI-assisted-by: opus-5
2026-11-11 17:38:10 +01:00
Álvaro Tejero Cantero
b3d4e9e7b7
🐛 Name datascript's externs in the builds that rename properties
datascript.query labels a datom's slots with the string names "e", "a",
"v" and "tx", and reads one back with a bracket access on the datom
object. Closure renames the field and leaves the string, so the read
succeeds and returns whichever field inherited the old name. The index
reads the same slots as real fields, so storage keeps answering
correctly and the corruption stays silent.

Datascript ships datascript/externs.js to pin those five names and
declares it in its deps.cljs. shadow-cljs collects that declaration
into a :deps-externs entry it never populates, and reads it only behind
an unset :cljsjs-externs flag, so the declaration is inert and the
:worker release names the file itself. It costs 170 B on the probe
build's 436 197 B.

bench.debug becomes the gate rather than a reproducer: six readings,
one per datom slot plus a join and an index control, each paired with
the answer it must give, exiting non-zero on a wrong one. The new
ds-probe build compiles it with the worker's release options. Without
the externs entry that build reads nil for every transaction, and a
vanilla shadow project loses the value slot as well, which is why the
gate binds each slot on its own.

The ab-bench pin stays at :simple, and its comment now names the
reason that still holds: one compiler configuration across both
engines, matching the 2026-08-21 V8 table.

Answers mirror:ds-advanced-corruption.

AI-assisted-by: opus-5
2026-11-11 17:38:10 +01:00
Álvaro Tejero Cantero
3300c809b6
🔧 Add the engine bench probe and the :advanced corruption reproducer
frontend/src/bench carries the datascript half of the same-session V8
A/B (core.cljs body shared verbatim with the datahike worktree) and
debug.cljs, the minimal reproducer for the :advanced query-layer
corruption: storage reads correct, full-scan values replaced by
hash-like integers, bound-value queries empty. The ab-bench shadow
build pins :optimizations :simple because of exactly that corruption.
Owned by mirror:ds-advanced-corruption.

AI-assisted-by: claude-fable-5
2026-11-11 17:38:10 +01:00
Álvaro Tejero Cantero
2b779d4b35
♻️ Make uses-token the vocabulary's three-place relation
The experiment entry records the rule as (uses-token ?s ?tok ?attr),
with the applying property a rule variable rather than a per-shape
attribute walk; the two-place form was this port's own invention.
shapes-using-token now reads the property from the rule's third place.

AI-assisted-by: claude-fable-5
2026-11-11 17:38:10 +01:00
Álvaro Tejero Cantero
782ab14e8b
✨ Retrofit the overlay: rules, resolved refs, intervals, tokens
Four architectural results of the 2026-08-20 study become structure
(docs.local/graph/20260820-report-graph-overlay-datascript.md):

The relation vocabulary is a rule set, not raw clauses: child-of,
descendant-of, instance-of, refers-to, fills-swap-slot and uses-token
are named once in app.graph.overlay.queries, and every consumer query
and backported beadpot assertion traverses relations through them, so
an encoding can change under a rule without a consumer moving.

The subtle helpers run in the builder, never in a query:
ctf/find-ref-shape is called once per copy shape at build time and its
answer is the plain :shape/refers-to reference. The sync path
re-resolves the affected set when resolution inputs change: the shape
on :shape-ref, the subtree on head attributes and reparents, every
instance subtree of a component whose record or copy moved.

Containment carries a second form: one global Euler-tour numbering,
one DFS per container from a single global counter, so "inside subtree
X" is two interval comparisons. Per-container counters are the bug the
suite pins against: ranges would overlap numerically across pages.
The sync path renumbers a container from beyond the global maximum;
interval containment, not the numbers, is the invariant.

Token applications fold the property into the attribute name:
{:token/fill "layerTwo.background"} over the closed vocabulary of
app.common.types.token/all-keys, behind the uses-token rule so the
choice stays reversible. The relation-entity encoding and its two
extra datoms per application are gone.

31 tests, 650 assertions, 0 failures across the three suites.

AI-assisted-by: claude-fable-5
2026-11-11 17:38:10 +01:00
Álvaro Tejero Cantero
96bc52a052
🔥 Retire the Cypher session sync and its gates
app.graph.sync existed to keep a live Ladybug session in step with the
editor's change vectors; with sessions on the overlay it has no caller.
Its parity suite and the binder gate move with it: the overlay sync
parity test replays the same change vocabulary against
app.graph.overlay.sync, and the console gate test guards the Datalog
surface where validate-on-connection! guarded Cypher. The validator
itself stays in app.graph.ladybug with the batch tier.

AI-assisted-by: claude-fable-5
2026-11-11 17:38:10 +01:00
Álvaro Tejero Cantero
98d141b5b7
♻️ Swap the graph console from Cypher sessions to the overlay
app.graph.debug sessions now hold a datascript overlay in an atom:
readers never lock, the msgbus sync loop is the only writer, and the
Ladybug engine no longer appears on the session path. The console
query box speaks Datalog; the default query lists component instances
by page, with filter_* columns carrying entity ids for the graph-view
result filter exactly as before. The node inspector asks
[:find ?a ?v :where [<eid> ?a ?v]] and renders datom pairs. Node ids
in the graph-view export are datascript eids, which keeps two shapes
sharing one uuid in different containers distinct; the Cypher export
merged them.

export-session-database! is retired with the session tier: an overlay
has no database file. The reproducible .lbug artifact stays on
source=file through app.graph.ingest, which keeps the batch-export
tier and the beadpot parity route alive.

AI-assisted-by: claude-fable-5
2026-11-11 17:38:10 +01:00
Álvaro Tejero Cantero
79cec680d6
🎉 Add datascript graph overlay over the document
An index of identity, type, and topology in datascript, replacing the
per-file Ladybug database for reverse and cross-container questions.
Entity identity is the (container, shape) pair, never the shape id
alone: uuid/zero keys every page's root frame, duplicated pages share
non-root ids, and deleted components keep their own copy of the
main-instance subtree under the same ids.

app.graph.overlay projects a file's data into the index: pages and
component containers, shapes, components, and the asset node kinds the
Ladybug projection never gained (colors, typographies, token sets,
tokens), with their link transforms from beadpot's ledger (UsesColor,
UsesTypography, UsesToken) as reference attributes and relation
entities. Color references are stored per source (fill, stroke, text
content) so the sync path can rebuild each contribution from a single
:set operation.

app.graph.overlay.queries holds the shared rule set and the standing
question set. The four Cypher edge kinds are rules over indexed
attributes, so they cannot drift from what they restate. The helpers in
app.common.types.component and friends stay the oracle: instance-head?
is the [?s :shape/component-id _] clause, never a reimplementation, and
the local upward walk stays with ctn/get-parent-heads when the caller
holds the document, because the pointer chain beats the rule walk by
three orders of magnitude.

app.graph.overlay.sync applies Penpot change vectors to the index,
pure db-to-db, mirroring app.common.files.changes/process-change per
clause, including load-component-objects on soft component deletion.
app.graph.overlay.console parses, gates, and formats free-form Datalog:
datascript resolves namespace-qualified symbols in call position via
resolve, so every call symbol must be a built-in, an aggregate, or a
shared rule.

The suites pin: overlay answers identical to the helpers on constructed
fixtures (177 assertions, beadpot's graph tests backported with each
claim verified against a helper), synced index equal to a rebuilt one
after every prefix of a 29-change replay covering all 12 supported
change types, and the console gate.

Measured on the Pencil design system (49 pages, 21 256 shapes, 730
components): build 1.63 s, 157 893 datoms, about 7.5 MB retained,
answers identical to the helpers across 724 components, 1 000 sampled
parent-head chains, and 5 549 frame subtrees; sync p95 0.28 ms per
commit. The Ladybug session tier measured 33 to 43 s and 144 MB for a
20k-shape file the projection can ingest; the projection still rejects
every real file at the Path node.

AI-assisted-by: claude-fable-5
2026-11-11 17:38:10 +01:00
Álvaro Tejero Cantero
720712138c
✨ Add datascript dependency for graph-overlay
First step of the graph-overlay port: index identity, type and topology
in datascript over the document, instead of projecting every shape
attribute into a Ladybug database. Backend and frontend both declare it,
because the index is meant to end up in the derived-index worker.

Measured motivation (devenv, JVM datascript 1.8.1): a 20k-shape file
indexes in 1.1s and 5.7MB, against 33-43s and 144MB for the equivalent
Ladybug session.
2026-11-11 17:38:10 +01:00
penpot-renovate[bot]
b438676d6c
⬆️ Update dhi.io/node image to f6ba5e2 (#12975)
- dhi.io/node 24.22.2-debian13-dev@f6ba5e2 (docker/images/Dockerfile.media-processor)
- dhi.io/node 24.22.2-debian13-dev@f6ba5e2 (docker/images/Dockerfile.mcp)
- dhi.io/node 24.22.2-debian13-dev@f6ba5e2 (docker/images/Dockerfile.exporter)

Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com>
Co-authored-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com>
2026-11-11 16:55:19 +01:00
Gabriel Ribeiro
7e9a3c3c28 📚 Fix typos in technical and user guides
Fix five misspelled words in the docs: "notifiy" and "occured" in the
integration guide, "enviroment" in the developer guide index, "succesful"
in the authentication subsystem page and "achive" in the layers page.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Gabriel Ribeiro <267068126+gabrielribeiro3105@users.noreply.github.com>
2026-11-11 15:24:13 +01:00
andrés gonzález
a301c2b43f
✨ Keep board names visibility after reloading the workspace (#12872)
Hiding board names from the View menu reset on every reload, while
rulers and comments kept their state. Add the flag to the layout flags
persisted in local storage so it behaves the same way.

Closes #12871

AI-assisted-by: claude-opus-5-5
2026-11-11 14:52:35 +01:00
Andrey Antukh
671ff4a05f Merge remote-tracking branch 'origin/staging' into develop 2026-11-10 20:06:46 +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
Andrey Antukh
c39e3e8301
✨ Strictly validate uploads against file bytes on disk (#12801)
* ✨ 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
2026-11-10 15:51:48 +01:00
Elena Torró
7f0b62b994
🐛 Keep drop shadows out from under translucent shapes (#12904)
* 🐛 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
2026-11-10 15:05:08 +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
penpot-renovate[bot]
c9d3cbb383
⬆️ update GitHub Actions (#12898)
- docker/login-action v4.8.0 (.github/workflows/build-renovate.yml)
- docker/login-action v4.8.0 (.github/workflows/build-renovate.yml)
- docker/login-action v4.8.0 (.github/workflows/build-renovate.yml)
- docker/login-action v4.8.0 (.github/workflows/build-renovate.yml)
- docker/login-action v4.8.0 (.github/workflows/build-renovate.yml)
- docker/login-action v4.8.0 (.github/workflows/build-renovate.yml)
- docker/login-action v4.8.0 (.github/workflows/build-docker.yml)
- docker/login-action v4.8.0 (.github/workflows/build-docker.yml)
- docker/login-action v4.8.0 (.github/workflows/build-docker.yml)
- docker/login-action v4.8.0 (.github/workflows/build-docker.yml)
- docker/login-action v4.8.0 (.github/workflows/build-docker.yml)
- docker/login-action v4.8.0 (.github/workflows/build-docker-devenv.yml)
- docker/login-action v4.8.0 (.github/workflows/build-docker-devenv.yml)
- zizmor 1.32.0 (.github/workflows/lint-workflows.yml)

Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com>
Co-authored-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com>
2026-11-10 13:48:10 +01:00
Miguel de Benito Delgado
cbd4c9634c
🔧 Use correct :cache-root in shadow-cljs.edn (#12883) 2026-11-10 13:47:50 +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
Juanfran
ae24118caf
🐛 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 13:44:26 +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
penpot-renovate[bot]
2e6c799bba
⬆️ Update dhi.io/node image to f943257 (#12931)
- dhi.io/node 24.22.2-debian13-dev@f943257 (docker/images/Dockerfile.media-processor)
- dhi.io/node 24.22.2-debian13-dev@f943257 (docker/images/Dockerfile.mcp)
- dhi.io/node 24.22.2-debian13-dev@f943257 (docker/images/Dockerfile.exporter)

Signed-off-by: penpot-renovate[bot] <338182062+penpot-renovate[bot]@users.noreply.github.com>
2026-11-10 11:45:57 +01:00
Filip Sajdak
ee3e2a0e61
🔧 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 11:33:42 +01:00
Filip Sajdak
90d98b1989
🐛 Call the team hook on every render of the team container (#12920)
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>
2026-11-10 11:32:58 +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
Eva Marco
72575e5c05
🐛 Make Add to an organization a keyboard button (#12722)
"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
2026-11-10 08:33:04 +01:00
Luke Lau
cd1ece4beb
⚡ Keep the translation flag on undo and redo of a move (#12870)
* ⚡ 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>
2026-11-09 20:47:42 +01:00