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
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
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
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
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
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
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
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
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.
Replaces the content-hash build key (bundle_version + docker/images tree hash) used to tag and dedupe the backend/frontend/exporter/storybook/mcp image set with sha-<commit>, matching the scheme already used by admin-console, licenses-manager and payments across the org. The check→build→promote pattern with the S3 marker is unchanged; only the key used for the marker, the immutable tag and the local bundle cache filename moves from the composite build key to the git commit sha (the bundle cache now keys on bundle_version alone, which is what it actually caches). devenv is intentionally left out of this pass, it has no versioned tagging today.
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
* ♻️ Share text layout paragraphs across modifier clones
Store Skia paragraphs in Rc so TextContentLayout::clone keeps the
cached layout for rotate/pan modifiers. Add layout.clear() and treat
needs_update as paragraphs-empty only.
* ⚡ Reuse cached Skia paragraphs when painting text
Add try_paint_from_layout_cache to paint from TextContent.layout when
versions match, skipping ParagraphBuilder rebuild and layout on each
frame. Wire into the layered text path for plain fills without strokes
or effects.
* ⚡ Paint plain text directly onto Current
Extend can_render_directly for stroke-free text and skip the empty
save_layer in draw_text when no stroke-group opacity is set. Plain text
paints into Current without the Fills/Strokes blit.
The .custom-select-dropdown used width: fit-content which, in Firefox,
is capped to the containing block width (60 px for the font-size input).
This caused two- and three-digit font-size values (e.g. 48, 120, 1000)
to be visually clipped in the dropdown list.
Switching to width: max-content lets the dropdown expand to fit its
content regardless of the containing block width, matching the behaviour
Chrome already exhibited with fit-content.
Fixes#11008
Shapes and text with stacked solid fills were exported without fill
attributes because merge_fills builds a Skia shader that SkSVGDevice
does not serialize. Draw each fill layer separately for SVG export,
bottom to top, matching the classic exporter.
Closes#11466
Upload structural shape attrs (base, children, blur, shadows,
flex, layout-item) via multi-shape `_set_shapes_batch` FFI in
chunks of 512, then apply host attrs with use-shape selection.
* ✨ Add text and @font-face to WASM SVG export
Emit selectable <text> elements and inject @font-face rules that
reference the font URLs registered at load time. Text fill bypasses
save_layer wrappers that SkSVGDevice drops. Text strokes are deferred
to a follow-up PR.
* 🐛 Strip trailing commas from Skia SVG text positions
Skia's SVG backend appends a trailing comma to per-glyph x/y lists.
Firefox rejects the malformed attribute and drops glyph positioning,
making exported text vanish or mis-render.
* ⬆️ Update pnpm and its deps
* ⬆️ Update JVM dependencies in backend and common
Update several JVM dependencies across backend and common:
- passay 1.6.6 -> 2.0.0 (package reorg, ctor-based rules)
- siphash 2.0.0 -> 3.0.0 (SipHasher* renamed to SipHash*)
- lettuce-core, guava, sqlite-jdbc, jsoup, lz4-java, markdown-clj,
awssdk s3/sts, selmer, jackson-core/databind, shadow-cljs
Adapt passay validation to the new API (moved packages, constructor
configuration) and siphash to the renamed classes. Add tests for
password validation and UUID advisory-lock hashing.
AI-assisted-by: deepseek-v4-flash
* ⬆️ Update node on docker images
* 📎 Minor fixes related to pnpm12 compatibility
* 🐛 Gate MCP REPL server behind isDevEnv check
The ReplServer was starting unconditionally on every MCP server
instance, regardless of configuration. This exposed an
unauthenticated POST /execute endpoint that forwarded arbitrary
JavaScript to connected Penpot plugins.
Gate ReplServer creation, startup, and shutdown behind isDevEnv(),
consistent with how CljsReplTool and other dev tools are already
protected. Log an info message when the REPL server is disabled.
Consolidate the dev-env check into a single static isDevEnvEnabled()
method that isDevEnv() delegates to, avoiding duplicate logic. Add
PluginBridge.close() for proper WebSocket server cleanup on shutdown.
Add regression tests that construct PenpotMcpServer and verify
hasReplServer() returns the correct value based on the dev-env flag.
AI-assisted-by: mimo-v2.5-pro
* ✨ Add PENPOT_MCP_REPL_ENABLE env var for explicit REPL control
Allow the REPL server to be enabled independently of the devenv
setting via a new PENPOT_MCP_REPL_ENABLE environment variable.
When set to "true", the REPL server starts regardless of
PENPOT_MCP_DEVENV; when set to any other value, it is disabled.
When unset, the previous isDevEnv fallback applies.
Addresses review feedback on PR #11282.
AI-assisted-by: mimo-v2.5-pro
The objects-gc task was performing a hard delete on profiles
without cascading the soft-delete to owned teams, projects, and files.
This left orphaned objects that were never cleaned up.
Now the task invokes delete-object before the hard delete, ensuring
all owned resources are properly marked for deletion and cleaned up
in subsequent GC iterations.
AI-assisted-by: qwen3.7-plus
Fix two security vulnerabilities in comment RPCs when accessed
via share-links:
- GHSA-4p97-v4wg-jxfx: Share-link holders with who-comment=team
could bypass the restriction and comment. The check-comment-permissions!
function treated can-read as sufficient, but share-links always set
can-read=true.
- GHSA-fwm4-hm9f-rmcp: Comment query RPCs returned threads from all
pages, ignoring the share-link's :pages restriction.
Changes:
- files.clj: Differentiate :membership vs :share-link in
check-comment-permissions!. For share-links, require
has-comment-permissions? only (who-comment=all).
- comments.clj: Filter threads by (:pages perms) for share-link
access in get-comment-threads, get-comment-thread, and get-comments.
Closes#11370
AI-assisted-by: qwen3.7-plus
The redis cache keys for the file library summary were built as
penpot.library-summary.<file-id>, missing the tenant component used
by the rest of the shared-infrastructure keys (rlimit, msgbus,
worker queues).
Build the key with a new file-summary-cache-key function that appends
the configured tenant, following the penpot.library-summary.<tenant>.<file-id>
pattern. Stale keys in the old format expire on their own with the
existing 30-day TTL.
Closes#11407
AI-assisted-by: glm-5.3-flash
Add owner protection to ::delete-team-member RPC command.
Previously, a team admin could remove the team owner, permanently
locking them out of their team and all resources.
Changes:
- Fetch target member data before deletion
- Validate member exists (return :not-found if not)
- Reject removal if target is owner and caller is not owner
This mirrors the existing protection in update-team-member-role.
Closes#11367
AI-assisted-by: qwen3.7-plus
* 🐛 Block IPv6 transition addresses in SSRF guard
The outbound HTTP SSRF blocklist did not classify NAT64
(64:ff9b::/96), 6to4 (2002::/16) or Teredo (2001:0000::/32)
addresses, whose embedded IPv4 target is invisible to the JVM
InetAddress predicates, so URLs resolving to them could reach cloud
metadata, loopback or RFC 1918 hosts from webhook delivery and media
import.
Transition ranges are now rejected outright and any embedded IPv4 is
re-checked against the full blocklist, including operator-supplied
extra blocked CIDRs.
Closes#11319
* ♻️ Remove dead embedded-IPv4 re-check from SSRF guard
The previous commit added a recursive re-check of the IPv4 embedded in
NAT64/6to4/Teredo addresses, but the `or` in `blocked-address?`
short-circuits on the truthy keyword returned by `transition-prefix`,
so the embedded-IPv4 branch was unreachable. The transition ranges are
already rejected outright (fail-closed), making the re-check both
unnecessary and untested.
Remove `transition-embedded-ipv4`, simplify the IPv6 branch to a plain
prefix check, and correct the docstrings and tests to match what the
code actually does.
AI-assisted-by: glm-5.3-flash
* 🐛 Filter share-link tokens in get-view-only-bundle response
The get-view-only-bundle RPC command returned all share-link tokens for a file, allowing an anonymous holder of a restrictive share-link to enumerate and use more permissive tokens.
When authenticating via a share-link, the response now only includes the share-link used for authentication, preventing token disclosure and scope escalation.
Implemented using TDD:
- RED: Test demonstrates vulnerability (all tokens visible)
- GREEN: Filter share-links when (:type perms) = :share-link
- Verified all existing tests still pass
Closes#11285
AI-assisted-by: qwen3.7-plus
* 🐛 Add membership-side test for share-link token visibility
Add test coverage for the allow side of the share-link token filtering:
team members and file owners should still see all share-links, while
anonymous share-link holders only see their own token.
This protects the (:type perms) = :share-link guard from accidental
regression that could break the owner's share-link management dialog.
AI-assisted-by: qwen3.7-plus
* ⚡ Optimize demo user setup for performance tests
Use UUID-based demo emails to prevent concurrent profile collisions.\nUse fast PBKDF2 hashing for demo profiles while keeping regular user hashing unchanged.\nAdd focused coverage for hashing, email uniqueness, and the feature flag.\n\nAI-assisted-by: gpt-5.6-luna
* 🐛 Harden font upload test setup
Report upload-session errors before chunk validation.
Skip chunk uploads when the session ID is invalid.
Remove unnecessary Mockery state from the foreign-font test.
AI-assisted-by: gpt-5.6-luna
* ✨ Add demo profile purge task
Schedule delayed deletion for demo profiles through the worker system.
Restore normal profile filtering and cover the purge handler with tests.
AI-assisted-by: gpt-5.6-luna
* 🐛 Add configurable limits for ZIP entry count and object size in v3 import
Add binfile-import-max-zip-entries (default 500,000) and
binfile-import-max-object-size (default 100 MiB) config entries.
Both are configurable via PENPOT_BINFILE_IMPORT_MAX_ZIP_ENTRIES and
PENPOT_BINFILE_IMPORT_MAX_OBJECT_SIZE env vars.
Entry count is checked before processing begins. Per-object size is
checked after each storage object content is resolved.
AI-assisted-by: mimo-v2.5-pro
* 🐛 Enforce actual decompressed byte limits on v3 import
The previous object-size check trusted the ZIP entry header's declared
size (ZipEntry.getSize()), which a malicious zip-bomb can forge. The
check would pass, then the full decompressed payload would be read
anyway during hashing and storage persistence.
Add size-limiting-stream, a FilterInputStream wrapper that counts
actual bytes read and raises :validation :max-file-size-reached when
the configured limit is exceeded. Wire it into zip-entry-storage-content
so both the hash calculation and storage write paths are bounded by
real decompressed bytes, not declared header size.
Also wire import limits into management.clj (clone-template) and
debug.clj (import-handler + clone path) for defense-in-depth, and
add a test that exercises the object-size limit with a real storage
object in the exported ZIP.
AI-assisted-by: mimo-v2.5-pro
* ✨ Add export job model, store and scheduler to exporter
* ✨ Render wasm exports on pooled worker threads
* ✨ Add export job REST API to exporter
* ✨ Use export job API and allow cancelling wasm exports
* 🔧 Show export jobs in the internal debug panel
* 🔧 Pass flags and export job settings to the exporter container
* 📚 Document the exporter job API and its redis layout
* MCP server: Update instruction manual to point to method instead of sleep
* MCP plugin: Call waitForLayoutUpdate prior to every code execution
* Composable test suite: Use waitForLayoutUpdate instead of 200ms delay in tests
* 🐛 Fix spacing between SSO buttons and email field
* ♻️ Update scrollbar on register page
* ♻️ Translate hardcoded string
---------
Co-authored-by: Eva Marco <evamarcod@gmail.com>
When an issue or PR is mentioned in conversation, fetch details via gh
CLI or scripts/gh.py instead of WebFetch. Use gh issue view for issues,
gh pr view for single PRs, and scripts/gh.py prs for multiple PRs.
AI-assisted-by: glm-5.3-flash