The throttled update loop report used the handled-exception event, which
only lands in the audit log: error reports and alerts read
unhandled-exception and exception-page. A throttled loop is an
application defect (its cause has :type :internal), and the point of the
report is to find those loops, so it is now an unhandled exception.
AI-assisted-by: claude-opus-5-5
When the scheduler throttles an update loop, it only logs a console
warning, so a loop that no longer crashes leaves no trace. Send an
error report too, with the events in flight and the last events, at
most once per report window.
Relates to #12580
AI-assisted-by: claude-opus-5-5
A thumbnail loaded from the server keeps a media id as :uri, and
workspace-thumbnail-by-id resolves it into a new map on each read. The
ref compared values with identical?, so every root frame, every
component in the assets panel and the interactions layer re-rendered
on every store change, mouse moves included. Compare by value instead.
This does not loop by itself, but it adds work to every commit and
brings other update loops closer to React's limit.
Relates to #12580
AI-assisted-by: claude-opus-5-5
Text image shaders are baked into Skia paragraphs at layout time.
touch_shapes_with_image only scanned shape.fills/strokes, so text
spans were never invalidated after store_image. Detect text content
image fills and force a layout rebuild before touching tiles.
Closes#12563
* 🐛 Skip unknown entries when importing shortcuts
The shortcuts import rejected the whole file when it held a single entry
it did not know, so a profile exported from an older Penpot version, or
edited by hand, could not be imported at all. The spec in #9924 asks to
skip unknown keys, tell the user which ones, and apply the rest.
Importing now keeps the entries that map to a known shortcut with a valid
key combination and skips the rest: unknown contexts, unknown shortcut
keys, and commands that are not a key combination. The import modal
lists the skipped entries. The file is only rejected when it is not a
map or holds nothing that can be applied.
A context whose entries are all skipped is left out: an empty context
clears the user's shortcuts on import, so keeping it would wipe them.
valid-command? reads a command the way mousetrap does, so it accepts
every default shortcut and rejects strings mousetrap could never match.
AI-assisted-by: claude-opus-5-5
* 📎 Sort requires in shortcuts settings test
cljfmt expects the namespace requires in alphabetical order.
AI-assisted-by: claude-opus-5-5
* ✨ Reserve cancel, copy and paste shortcuts
Cancel, copy and paste are no longer customizable, in the workspace and
in the path editor, which shares their shortcut keys. Their key
combinations (Escape, Ctrl/Cmd+C, Ctrl/Cmd+V) are now reserved: the
shortcut recorder warns and blocks saving one of them for another
action, and the import skips entries that use them.
Reserved combinations come from the non-customizable workspace
shortcuts, so marking another one as non-customizable reserves its key
too. The path editor's cancel also binds V, the move tool key, so path
shortcuts are left out. Combinations compare the way mousetrap matches
them: aliases and modifier order do not matter.
apply-custom-overrides now ignores overrides of non-customizable
shortcuts. An override saved before this change would otherwise still
apply, with no way to reset it from the settings page.
AI-assisted-by: claude-opus-5-5
* 🐛 Pass version tag to worker so it loads the current render wasm
* 🐛 Keep wasm thumbnail queue alive when one render fails
* 🐛 Fail wasm thumbnail request when canvas context cannot init
* ✨ Support running several exporter replicas
* 📚 Document one exporter per machine instead of compose replicas
* 📚 Trim exporter replica docs to the essentials
* ✨ Cancel an exporter's jobs when it shuts down
* 📚 Replace exporter replica docs with a short beta note
* 🐛 Fix legacy path migrations on content without coordinates
PRE still returned 500 on get-team-shared-files after #12357, now with
"expected rect instance" in legacy-6. When a path has no points, legacy-6
rebuilds its rect from the content, but content whose points are not
numbers (NaN or infinite coordinates, segments without params, only a
close path) gives no rect either, and rect->points rejects nil.
Fall back to the empty rect in legacy-6. A path with segments missing
coordinates then failed later, in 0003-convert-path-content-v2, since the
binary path data can not store them; drop those segments there.
Relates to #12343
AI-assisted-by: claude-opus-5-5
* 🐛 Skip shared files that fail to load in get-team-shared-files
get-team-shared-files loads and migrates every shared file of the team,
so one file whose migration fails made the whole request return 500 and
the "Add library" modal could not open.
Catch the error per file, log it with the file and team ids, and leave
that file out of the list. The other libraries still show, and the error
report names the broken file.
Closes#12343
AI-assisted-by: claude-opus-5-5
`SET STORAGE DEFAULT` needs PostgreSQL 16, but the official docker
images pin `postgres:15`, so migration 0156 failed on those
deployments. The migration now uses `SET STORAGE EXTENDED`, the
storage mode of the type of every column it touches (text, text[],
jsonb and bytea), which leaves the catalog in the same state on any
supported version.
The documented PostgreSQL floor goes back to >=15, the reason for
raising it was that syntax.
Verified on the test database: the 62 columns reset and no table
column keeps the legacy EXTERNAL storage.
The backend memory now records that migrations must stay within
PostgreSQL 15 syntax.
AI-assisted-by: minimax-m3.1-flash-free
The prod-infra memory now says that the `$host` multi-variable needs
its `query`, its `options` list and its `allValue` regex updated
together. A host present only in `query` is dropped when `All` is
selected, so the panels that filter with `instance=~"$host..."` stop
showing it. Panel 104 is the visible symptom.
AI-assisted-by: minimax-m3.1-flash-free
`app.redis/del` logged `:key key`, but `key` is not bound in that
`let`: it resolved to `clojure.core/key`, so the line carried
`#object[clojure.core$key ...]` instead of the keys that failed to be
deleted. Log `:keys keys`, which is always the normalized vector, so a
single key and a collection report the same shape.
The tests pin the line: the keys, the hint, the error level, the logger
and the timeout as cause, plus the `nil` the caller gets.
The prod-infra memory now notes that client-side redis timeouts mean the
JVM is paused, not that the server is slow.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Fix render_to_pdf call in the ToUnicode test
The test passed a scale argument that render_to_pdf does not take, so
the render-wasm test build failed to compile.
* 🐛 Draw masked group inner shadows in PDF and multi-element export
The CPU renderer behind PDF and multi-element raster export drew a
masked group's drop shadow but never its inner shadow, so the masked
content came out flat. Wrap the masked content in a layer that draws
the inner shadows over it, as the GPU single export does.
* 🐛 Export every visible inner shadow of a masked group
The workspace skips shadows too small to see at the current zoom, to
render faster. Export has no such need, so draw every visible inner
shadow instead of filtering by scale.
Noto Sans Symbols lacks most arrows and math operators. Fall back to
Noto Sans Math for them, and to Noto Sans for letterlike symbols.
Closes#12469
AI-assisted-by: claude-opus-5-5
WASM find_guide_at only checked distance on the guide axis, so board-bound
guides stayed interactive outside their board. Respect frame_range in
GuidePool hit testing so interaction matches the drawn solid segment and
the SVG hit area.
Closes#12322
Skia maps ligatures and other substituted glyphs to U+0000 in the PDF
ToUnicode maps. Rebuild those maps from the laid-out paragraphs and
append them as an incremental update.
Closes#12393
The exception page compared the screen query param against literals
that never occur ("view", "dashboard"), so viewer and dashboard urls
of inaccessible files and teams fell through to the plain not-found
page instead of the request access modal. Classify the param with a
tested helper that recognizes "viewer" and every "dashboard-*" route.
Closes#11224
AI-assisted-by: muse-spark-1.3-contributor
Add the missing 2.18.4 entries for the closed storage GC,
version quota and file_change revision issues, with their
fix PRs referenced inline.
AI-assisted-by: muse-spark-1.3-contributor
Drop the legacy TOAST STORAGE EXTERNAL overrides set when PostgreSQL
ran on ZFS with transparent compression. Migration 0156 restores the
type default (SET STORAGE DEFAULT) on 62 columns across 21 tables;
existing TOAST data keeps its strategy until rewritten.
Raise the documented PostgreSQL floor to >=16, required by the new
SET STORAGE DEFAULT syntax.
AI-assisted-by: muse-spark-1.3-contributor
Inner shadows were joined into extrect via the drop-shadow filter, so a
Y offset grew the PNG/SVG export surface. Only drop
shadows expand bounds, matching classic export. Closes#12386.
Comment out the media-storage-bytes-per-team check in
upload-file-media-object. The SUM over storage_object takes
3-12s per upload on prod (3.5TB DB, ~34 slow runs/hour)
and blocks uploads. Keep the SQL and quote definition
for re-enable with a cheaper implementation.
Update the upload quota test to document the disabled
state until the new implementation lands.
AI-assisted-by: muse-spark-1.3-contributor
A modified shape is a cached copy, and edits through get_mut did not
reset it. After Enter the preview stops sending modifiers while the size
holds, so the canvas kept painting the old text.
AI-assisted-by: claude-opus-5-5
Binfile v1/v3 import and the v3 export get-file step hold a single
transaction while the ZIP is decoded and the file data is realized.
Disabling idle_in_transaction_session_timeout left a stuck connection
holding locks (and a pool slot) forever.
Raise it to 30 minutes instead. The longest import/export observed in
production is under 8 minutes, so the limit still bounds a stuck
session. statement_timeout stays at 300 s.
Add set-transaction-idle-timeout and configure-import-transaction in
app.binfile.common, use them from both paths, and cover the helper
with a test.
AI-assisted-by: deepseek-v4.1-flash
* Revert "🐛 Fix shortcuts with modifiers on letters and digits (#12292)"
This reverts commit 21ecfb67b70ac2d6f4defb05a543f0fc18d692a2.
* 🐛 Migrate mousetrap to key with sane modifiers
Finish the half-done mousetrap migration from code/keyCode to
key: canonical lowercase tokens on both bind and event sides so
modifiers match again, US-only shift remap and keypress legacy
removed, and AltGr phantom ctrl filtered on Windows.
Record punctuation positionally through getLayoutMap (American
fallback) so Shift+Period stores shift+. on every layout, while
letters and digits stay glyph-based for AZERTY. Drop dead 219
aliases and revert the stray CamelCase escape command.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Rewrite mousetrap as ESM class without behavior changes
Turn the 2012 constructor-plus-prototype shape into a class
with underscore-private methods, const tables and arrow
listeners. Drop dead _belongsTo, fold one-line prevent/stop
helpers into _fireCallback, and let handleKey absorb the old
_handleKey indirection. Public API and matching logic stay
identical; verified by existing tests plus a runtime smoke.
AI-assisted-by: muse-spark-1.3-contributor
* 🎉 Add node:test suite for mousetrap shortcuts
Cover canonical key normalization, modifiers with AltGr,
bind arrays plus aliases keyup reset overwrite, sequences
with fake timers, and the full stopCallback matrix, all
through isolated instances and synthetic events. Hook the
zero-dependency suite into frontend/scripts/test for CI.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Record shortcuts by glyph instead of position
Drop the getLayoutMap positional layer and the
American fallback: punctuation now records event.key
lowercased, the same token Mousetrap dispatches, so
Shift combos match with and without a layout map.
Keeps the Numpad numN branch both sides share.
Updates the pinned positional tests and adds the
Spanish Shift-plus regression.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Resolve shifted digits to the digit itself
Shift+DigitN now resolves to the digit on every layout, so
shift+0 fires on QWERTY ()) as well as AZERTY (0), like the
old keyCode behavior. Applies in the recorder and Mousetrap;
unshifted digits and numpad are untouched. Also stages the
layout-map suite left untracked.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Cover punctuation defaults with layout twins
Some defaults only fired where their glyph is unshifted (+ dead
on US layouts, shift++ dead outside ES/DE, \ dead where it
needs AltGr). Bind the twin token per layout family and freeze
the display with :show-command. No tooltip or translation
changes.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Warn when the keyboard layout API is missing
Shows a warning on the shortcuts settings page in browsers
without navigator.keyboard.getLayoutMap, where Shift with
punctuation may not fire. Uses a synchronous probe, English
and Spanish strings, and documents the layout limitations in
the user guide.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix hide-ui twins and qualify layout docs
The ctrl+alt+\ twin could never fire (the AltGr filter drops
Ctrl), so it goes away; alt+º covers Chrome on Spanish
layouts where AltGr+º resolves to º. The user guide now
states which claims need the layout map and which hold
everywhere.
AI-assisted-by: muse-spark-1.3-contributor
---------
Signed-off-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
get-initials used an ASCII-only [a-zA-Z] match, so names like Ábaco
and Órbita skipped the leading accented letter. Match Unicode letters
with \p{L} (CLJS RegExp + u/g flags) so org default avatars match the
expected first letter. Closes#12324.
AI-assisted-by: Cursor <cursoragent@cursor.com>
Headless raster/PDF used the board selrect so overflowing children stayed
cropped, but that also clipped the board's own drop shadow. Use
export_root_bounds: own strokes/shadows/blur without joining children.
Closes#12388
* 🐛 Resize fit-content flex boards while typing in v3 text editor
Typing in an auto-width text did not resize its fit-content layout
parent until the editor closed. The v3 editor now previews the live
text size in the layout on each keystroke, also when the text sits
in a group.
* 🐛 Preview text layout when typing with a pending caret style
Both v3 editor commit paths share v3-update-editor-content.
* 🐛 Skip the layout preview for absolute texts
Absolute layout items do not affect their board.
* ⚡ Preview text layout only when the text size changes
Each preview propagates modifiers, so skip unchanged sizes.
* 🐛 Leave interactive render mode when the v3 editor closes
Finalize only ends it when it applies geometry.
* 🐛 Keep file_change revisions on their own deletion schedule
Restoring a file from the trash cleared the deletion mark on every
file_change row of that file, revisions included. A revision is born
already scheduled for deletion one hour later, and objects-gc only
looks at rows that carry a date, so unmarking one left it in the
table for ever: production had 358,517 of them across 3,614 files.
The file deletion cascade had the mirror problem: it rewrote the date
of rows that already had one, pushing a revision out to the trash
lifetime.
Restrict the restore to durable history and date only the rows that
have no date yet, so a revision keeps the schedule it was born with.
A pinning test covers the snapshot update path, which cannot reach a
revision because it resolves its row through a read filter. The
backend memories now carry the model of the three file_data classes
and this invariant, in place of the two superseded stubs.
Closes#12418
AI-assisted-by: deepseek-v4.1-flash
* 📚 Add autovacuum and Postgres exporter notes to Grafana memory
Record what this Grafana instance can and cannot show about autovacuum
so the next session does not have to re-derive it:
- The Postgres exporter scrapes the primary as well as the
read-only replica, so the primary's pg_stat_activity and
pg_settings_* are in Prometheus even though the SQL
datasource cannot reach them.
- It publishes no pg_stat_progress_* collector, and
pg_stat_activity_count has no backend_type label: autovacuum
workers are the usename/application_name-less bucket with
state=active. The launcher shows up as the AutoVacuumMain
wait event, and it is absent on the replica.
- A metric the exporter does not publish needs a custom query
block in its YAML queries file and a restart of the exporter.
- The Postgres server ships its own log to Loki (the job is
postgresql, primary only), whose streams carry only filename,
instance, job and level. The completed vacuum lines live
there: a per-table history, not a view of what runs now.
- The PostgreSQL section now ends with the vacuum panels,
with the Vacuum TXID age table last; an in-flight per-table
view would need a pg_stat_progress_vacuum collector in the
exporter itself.
AI-assisted-by: deepseek-v4.1-flash
With the SVG renderer, resizing a shape with an image fill and the mod
key showed the picture scaled during the drag, as in a normal resize.
The crop only showed after releasing the mouse.
The SVG preview applies the resize matrix to the shape node and ignores
the fill transform change carried by the modifiers. Now the pictures of
the shape fill patterns get a matrix that takes them from the old fill
transform to the new one, which undoes that resize and keeps them in
place. This works for shapes and boards.
Closes#12349
AI-assisted-by: claude-opus-5-5
* ✨ Extend backend histogram buckets beyond 7.5s
Give every backend timing histogram a tail past the shared 7500ms cap
so slow RPC calls, CLIMIT waits and background tasks report real
quantiles instead of clipping at the top bucket.
Request and wait metrics grow to 60s, background tasks to 300s and
Redis eval to 10s; the sub-7.5s buckets stay untouched to keep the
history comparable.
Closes#12408
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix flaky assertion in climit republish test
Read the last published waiting value instead of the exact
publication list. The sampler thread started with the component
runs its first tick asynchronously, and when it lands inside the
recording window it publishes the same count twice, which made
the full-suite CI run fail while focused runs stayed green.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Add file_data blob to JSON decode helper
Allow decoding file_data blobs (app.util.blob) to JSON
for investigations with jq, offline from a blob file or
from nREPL with the backend running.
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Add validation step timings to update-file logs and metrics
Measure the six phases inside process-changes-and-validate
(resolve-libs, process-changes, soft and hard affected and
schema validations) with tpoint and try/finally, so timings
are recorded even when a hard validation throws.
Publish each phase to a new histogram
penpot_rpc_update_file_validation_timing with a fixed step
label using the request buckets (up to 60000 ms), and extend
the update-file debug log with the same *-ms fields plus
file, project and team ids. The deferred log only runs on
success, so failures get an inline error log with partial
timings.
AI-assisted-by: muse-spark-1.3-contributor
* 💄 Translate decode-file-data comments to English
Translate the Spanish docstrings, section headers and test
messages of the file_data decode helper to English, keeping
code untouched. The repo convention is English in code and
comments; the helper was first written in Spanish.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Count saved versions in the version quota
The snapshots-per-file and snapshots-per-team quotes counted the
saved versions by file_change.data, a column that only the legacy-db
file data backend writes: versions keep their payload in file_data,
so the count returned 0 and a configured quota never applied.
Count by label instead, which tells a version apart with every file
data backend, and add a partial index on file_change with the same
predicate so both counts are answered by an index only scan instead
of walking every row of every file of the team (10.3 seconds in
production for a team with 829 versions).
Closes#12404
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Drop the ! suffix from the snapshot quota test helpers
The check helpers assert the outcome of a command instead of mutating
anything, so they do not take the ! that this codebase reserves for
functions whose contract changes state or runs a command (the
th/command! call inside them already carries it).
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Fix export scale dropdown clipped in Inspect
The export selects in the Inspect tab used a dropdown class that always
opened them upwards, overriding the select's own up/down choice. On
elements with few properties the export section sits near the top of
the panel, so the scale list (now eight options) went past the top of
the scroll area and hid 0.5x. Keep only the dropdown width and let the
select pick the direction.
Closes#12346
AI-assisted-by: claude-opus-5-5
* ♻️ Move Inspect exports styles to DS tokens
Replace the deprecated spacing and size variables, the flex mixins, the
color aliases and the typography mixins with DS tokens, and use logical
properties. The typographies switch to the closest DS ones: body-small
for the suffix input (line-height 1.3 instead of 1.4) and headline-small
for the export button (12px instead of 11px). The @extend placeholders
still come from refactor/ and are left for a later migration.
AI-assisted-by: claude-opus-5-5
* ♻️ Use DS buttons in Inspect exports
Replace the add, remove and export buttons in the Inspect exports section
with the DS icon-button and button, as the workspace export section does.
Delete their now unused styles and the sizes import; the export button
class only stretches the button and centers its label.
AI-assisted-by: claude-opus-5-5
* 🐛 Fix add export button lookup in the Inspect exports e2e test
The add export button in Inspect is a DS icon button now and lost its
.add-export class, so the test timed out waiting for it. Find it by its
role and accessible name instead.
AI-assisted-by: claude-opus-5-5
* 🐛 Fix token tree collapsing repeated path segments
Tokens whose path contains the same segment name twice in a row
(e.g. test.pointer.pointer) collapsed to a single level in the
token tree.
build-tree-node marked a node as a leaf when the current segment
name matched the first segment of every remaining path. Because
group-by-first-segment rewrites nested paths into the segment
name, a still-nested remaining path was indistinguishable from a
token ending at the current node, so test.pointer.pointer merged
into one leaf.
Mark rewritten segments with an internal ::remaining? marker and
strip it when the rest-path is fully consumed; is-leaf? only
accepts unmarked remaining segments as leaves.
Closes#11588
AI-assisted-by: glm-5.3-flash
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
* 🐛 Preserve ancestor tokens in path trees
Keep a node's token and descendants independently when imported paths
contain both an ancestor and a child. Render both in the token tree and
include the ancestor token in existing path-based group operations.
Cover accepted same-set imports, leaf IDs, repeated segments, and internal
marker removal without changing import or collision validation.
AI-assisted-by: GPT-6
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* ♻️ Rework token tree path building
Carry the segments left to descend as a ::remaining-path vector on each
segment and advance it by subvec, instead of rewriting the segment :name
and re-splitting it later.
With that, build-tree-node finds the leaf token with a predicate over
::remaining-path and takes children from the segments that still have
one, so the pre-grouping children-first sort no longer changes the
result. Drop sort-children-first and its segment-has-children? helper,
and document inside-path?.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Fix and relocate the token tree tests
The ancestor-and-descendant test called a function that does not exist
in app.common.types.tokens-lib, so the namespace failed to load and the
whole common suite errored. Use make-tokens-status-from-lib and the
two-argument get-tokens-in-active-sets instead.
Move the build-tree-root tests to path-names-tree-test so
path-names-test stays a pure split/join unit test.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Refactor the token tree rendering and share path helpers
Extract the token pill into a leaf-node* component and treat a node as a
leaf only when it holds a token and has no children, so a node can carry
both a token and its children.
Make token-separator public and reuse it from the token tree and the
management panel instead of local copies. Both management path filters
inlined the same ancestor-or-descendant rule; extract it as
token-in-path? and cover it with a unit test.
AI-assisted-by: deepseek-v4.1-flash
---------
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: luisddm <luis.dedios@kaieldos.net>
The implement-plan flow asked to commit the closed plan together with
the code, so the resulting commit looked like it was missing a piece.
State the rule in the three places that describe it: the implement-plan
skill, the commit workflow memory and the agents README. A plan is
session bookkeeping, not part of the change, and its absence from the
commit is correct rather than a missing piece.
AI-assisted-by: deepseek-v4.1-flash
Local scratch files and autogenerated build output were
showing up in git status:
- /*.html and /*.json cover local reports and token
dumps at the repo root; !/package.json keeps the
tracked root manifest visible to git.
- /media-processor/target/ and /scripts/__pycache__/
cover build and Python cache output.
- render-wasm artifacts copied into the exporter go in
their own commented block.
Record the exporter and render-wasm generated paths in
the module memories so nobody edits or commits them by
hand.
AI-assisted-by: deepseek-v4.1-flash
* 🚑 Reject forged upload paths outside multipart
Transit bodies can smuggle upload maps whose path points at any
server file; the schema only checks the shape and handlers open
the path directly.
app.rpc now injects a server-side multipart flag that bodies cannot
forge, and methods declare their upload keys with the new
multipart-only-params metadata, enforced centrally after schema
validation with a params-validation error. Covers all nine upload
methods, including chunked and management flows.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Sanitize raw explain in generic validation error bodies
The generic :validation branch returned ex-data untouched, so errors
carrying the raw malli explain (live schema objects, e.g. storage
invalid-storage-metadata) broke JSON encoding mid-stream and clients
got truncated bodies. Apply the same clean treatment the known codes
already use: drop the raw explain and attach its humanized text.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Add e2e path traversal tests for binfile and media upload
Add two e2e suites that hand-write transit bodies with a forged
["~#path", ...] upload path (no transit library needed) and expect
400 validation/params-validation. They cover import-binfile :file,
upload-file-media-object :content and upload-chunk :content, plus a
plain-JSON string-path control case. Before the multipart-path fix
they failed (500 io-exception, invalid-image, 200 stored chunk);
after it they pass.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Use multipart helper in binfile SVG test setup
The SVG import setup uploaded through plain command!, so the new
multipart-only guard rejected it with params-validation. Switch
the setup to multipart-command! like the other upload tests.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Use multipart helper in remaining upload tests
The multipart-only upload guard rejects plain RPC calls carrying
upload maps. Five tests with real files still used command! and
failed with params-validation. Switch them to multipart-command!
while keeping the forged-content tests on plain command!.
AI-assisted-by: muse-spark-1.3-contributor
A body declared as JSON that clojure.data.json cannot read ended as a 500
:unexpected with the whole stack trace in the log: wrap-parse-request
classified the failure by exception class, and the library signals its
syntax errors with a bare java.lang.Exception.
Classify it where it happens instead: read-body-params reads the body and
raises :validation :malformed-json (400) for anything it cannot parse,
keeps RequestTooBigException at 413 and re-raises a structured error from
deeper untouched. The body is read outside the handler try, so the error
goes out as a throw like the other specific-exception branches.
The failure is now one log line (path, peer address, content-type, parser
message) without the stack trace, and it is counted: the middleware takes
the metrics instance as a middleware argument, so the backend exports
penpot_http_malformed_requests_total and nexus declares its own
nexus_http_malformed_requests_total.
Tests: parse-request-malformed-json-body reproduces the production body
and asserts the 400 and the counter; the existing parse-request tests now
pass a metrics stub.
AI-assisted-by: deepseek-v4.1-flash
The new skill turns HEAD's stacked subjects, bodies and repeated trailers
(a typical result of squashing) into one message that follows
mem:workflow/creating-commits: a single imperative subject, a body
covering the whole branch diff, then the trailers.
It collects every distinct AI-assisted-by value before rewriting, so an
attribution is never dropped, and it requires ./scripts/check-commit to
exit 0 after the amend. It never pushes: rewriting history over commits
the user already pushed is their call, not the agent's.
AI-assisted-by: deepseek-v4.1-flash
The opencode build stage was already commented out, so the image carried
a dead block and a commented COPY. Drop both: opencode now comes from
scripts/setup-opencode, which installs @opencode/cli as a global pnpm
package in the penpot user's $PNPM_HOME. That dir sits in the user-data
volume, so the install survives a container re-creation and needs no
root.
pnpm 12 keeps the global binaries in $PNPM_HOME/bin, so put that dir on
PATH in the bashrc (and make the guard check the same path) instead of
$PNPM_HOME, which left the commands unreachable.
The entrypoint now also chowns ~/.local, ~/.cache and the dotfiles it
copies from /root. EXTERNAL_UID remaps penpot to the host UID on every
start, so the ownership the image set at build time stops matching; that
broke global pnpm installs and pnpm's own packageManager download.
AI-assisted-by: deepseek-v4.1-flash