select* resets its label to `default-selected` whenever its options
change identity. The access token dialog builds its options vector
inline, so picking an expiration re-rendered the dialog and the label
snapped back to "Never", although the form kept the picked value and
the token was created with it.
form-select* now passes the form value as `default-selected` when it
holds a non-blank string, so the reset lands on the picked option. The
caller's default still applies while the form value is blank.
Closes#11663
AI-assisted-by: claude-opus-5
Signed-off-by: chulgil <2044587+chulgil@users.noreply.github.com>
Co-authored-by: chulgil <2044587+chulgil@users.noreply.github.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The summary step reads a filter from .github/scripts, which is absent
on branches predating it, so a missing file turned otherwise green
runs red. Tolerate both a missing filter and a jq error: a cosmetic
summary should never fail a run.
Use `!cancelled()` instead of `always()` on the merge job. With
`always()` it also ran when the run itself was cancelled by
cancel-in-progress, after the shards had been killed without uploading
their blobs, leaving the download step with nothing to match.
Lower the JSON report retention to 7 days, the repository maximum.
Requesting 30 was silently clamped and logged a warning on every run.
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
Adds a 3x entry to the export scale selector in the workspace design
sidebar and in the viewer inspect panel, so @3x assets can be exported
natively without resizing them in external tools.
Signed-off-by: Shreyash Agare <agareshreyash26@gmail.com>
Co-authored-by: Shreyash Agare <agareshreyash26@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
The repository defines an EPIC issue type, but the memory only
listed Bug, Enhancement, Feature, Task, Question and Docs. Add
the EPIC row with its type id and its mapping entry so umbrella
tracking issues are typed consistently.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Pass the base branch and the project when opening a PR
The create-pr skill resolves the target branch with
scripts/detect-target-branch in step 1 and then never passes it, so
gh opens the PR against the repository default. A branch cut from
staging therefore targets develop. mem:workflow/creating-prs also
requires --project "Main", which the skill's command omitted.
Two checks were missing as well. A branch behind its base produces a
verdict about a tree nobody will ship, because checks run against head
merged into base. A draft PR runs no checks at all, since every test
workflow is gated on the pull_request.draft flag, so the skill now
asks before opening one and states that cost.
Finally, the AI-assisted-by trailer belongs in the PR body: Penpot
squash-merges, so the landed commit message comes from the PR title
and body, and a trailer that lives only on branch commits disappears
at merge.
AI-assisted-by: claude-opus-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
* ♻️ Drop the behind-the-base check and fix the trailer note
Review found both claims wrong. The behind-the-base check said the
checks would report on a tree nobody ships, but every test workflow
runs actions/checkout with no ref, so a pull_request event builds
refs/pull/N/merge, which is head already merged into base. Being
behind the base therefore changes nothing about the verdict, and the
check only forced a rebase for no gain.
The trailer note claimed a trailer on branch commits disappears at
merge. It does not: GitHub's squash body lists the commit messages,
and 91b433985a carries three AI-assisted-by lines from the two
commits of #11677. The paragraph now asks for the trailer in the body
as a courtesy to the reviewer, once per model, and says the commits
keep theirs.
AI-assisted-by: claude-opus-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
---------
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
* ⚡ Fetch only caller share-link in view-only bundle
Share-link callers now resolve a single row with a composite
(id, file-id) predicate instead of loading all sibling rows
and filtering in memory. Membership path keeps full query.
Related #11633
AI-assisted-by: muse-spark-1.3-contributor
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* ⚡ Add DB-access regression test for share-link bundle
The share-link path must resolve the caller row with a composite
(id, file-id) single-row lookup and never run the full
file-id query. Keep cross-file replay coverage.
Related #11633
AI-assisted-by: muse-spark-1.3-contributor
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
---------
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Add upload_session_chunk indirection for chunked uploads
Chunks now live in the upload_session_chunk table with non-deleting
foreign keys to storage_object and upload_session, instead of tempfile
objects with session metadata. Reads go through a JOIN, so chunk state
never scans storage_object.
Uploads validate the live session, reject duplicate indexes, and store
objects in the new upload-session bucket without extra metadata.
Assemble removes mappings and marks the session consumed; objects-gc
procedurally purges consumed and stalled sessions, touching referenced
objects first. Touched-gc and deleted-gc handle the new bucket, and
upload-session-gc is removed.
Closes#11644
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix quota, give-up and coverage for session chunks
Exclude consumed sessions from the sessions-per-profile quota so
finished uploads free their slot at once. Remove chunk mappings before
the gc-deleted give-up delete to respect the NO ACTION keys. Catch
java.sql.SQLException for duplicate chunks. Cover the profile-owned
session purge and the UNIQUE race backstop with tests.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Align chunked upload tests with upload_session_chunk
Drop the duplicate-index tests written against metadata-backed
chunks; the UNIQUE mapping makes those cases unrepresentable and
the new tests cover them. Rewrite the rejected-duplicate tests to
expect :validation/:chunk-already-exists and assert against the
upload_session_chunk table, and scope the chunk-too-large "nothing
stored" check to the mapping table.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Use NO ACTION DEFERRABLE session FKs in single migration
Fold the profile FK change into 0154 so the feature ships one
migration. All three upload session FKs use ON DELETE NO ACTION
DEFERRABLE: identical to RESTRICT in normal operation, but deferrable
for tooling that relies on SET CONSTRAINTS ALL DEFERRED. Extend the
RESTRICT test to the direct profile delete.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Reserve chunk slot before writing blob in upload-chunk
Make object_id nullable and insert the mapping with NULL inside the
session-locking transaction, then write the blob outside it and link
it with a conditional update. A failed write removes the mapping and
reraises; a mid-flight death leaves a NULL row and the client starts
a new session.
AI-assisted-by: muse-spark-1.3-contributor
* 🔥 Remove redundant session_id index on upload_session_chunk
The UNIQUE(session_id, chunk_index) btree already serves
session_id-only lookups and the session FK check through its
leftmost column, so the standalone index only taxed the
per-chunk INSERT path. Verified with EXPLAIN on an equivalent
table shape.
AI-assisted-by: muse-spark-1.3-contributor
* ⚡ Merge chunk touch and delete into single RETURNING query
Replace the SELECT-then-DELETE round-trip in
delete-upload-sessions! with DELETE ... RETURNING object_id,
touching each returned object. Same semantics, one less query
per purged session. Follows the RETURNING pattern already used
in file-gc.
AI-assisted-by: muse-spark-1.3-contributor
* ♻️ Let objects-gc own chunk mapping deletion
Assemble-chunks now only marks the session as consumed; the
chunk mappings stay until objects-gc purges them (touching the
chunk objects first), leaving a single procedural deletion
path for consumed, stalled and profile-purge sessions.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix font-deletion GC expectations for chunk objects
Update final storage-gc-touched counts to include the two
chunk objects touched by objects-gc when purging consumed
upload sessions (8/5/5 instead of 6/3/3).
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Release chunk reservation when the link UPDATE fails
Review feedback on #11651: the link UPDATE in upload-chunk could
leave a NULL reservation behind, blocking retries of the same
index with :chunk-already-exists. Remove the reservation when
the link fails so the client can retry in the same session;
the orphaned blob stays touched for touched-gc. Also realign
the process-bucket! cond branches in gc-touched.
Tests: chunked-upload-link-failure-releases-slot and
chunked-upload-null-reservation-blocks-retry.
AI-assisted-by: muse-spark-1.3-contributor
Move SPA routing out of the URL fragment into the normal query
string. The screen travels in a reserved `screen` key holding
the route name (`?screen=workspace&team-id=…`); every other
param keeps its name. `rt/nav` and `rt/resolve` keep their
signatures.
This deletes the fragment-mirroring URL surgery, simplifies
link-preview (the server sees everything) and nginx (single
path, no SPA fallback rules needed), and migrates OIDC
redirects, email links, e2e helpers and plugin test utils to
the new format.
Legacy `#/…` URLs translate client-side for one Penpot version
(`legacy-routes`, marked TODO(next-version)); non-SPA paths
are untouched.
AI-assisted-by: muse-spark-1.3-contributor
The session-renewal test called bare make-dummy-request, which
no longer resolves. Qualify it with the existing th alias for
backend-tests.helpers, matching every other call site.
AI-assisted-by: muse-spark-1.3-contributor
The three locations of nginx-external-locations.conf define their own add_header directives, which under nginx's inheritance rules discards every add_header from the enclosing server block. They were therefore served without any of the four security headers already shipped, and would equally have been served without the new CSP and HSTS ones.
Include the common file in all three, which is the pattern the development environment already follows for the equivalent locations.
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
Ship both headers from the image so that every deployment starts from a sensible default instead of each installation deriving its own policy. Report-only mode never blocks a request, so this changes no behaviour for existing deployments, and HSTS stays absent unless PENPOT_PUBLIC_URI declares an https scheme.
The policy can be narrow because the frontend already reverse proxies its own external dependencies, so 'self' covers them. What it must permit beyond that comes from the code: 'wasm-unsafe-eval' for the render engine, 'unsafe-inline' styles for the inline style attributes of the UI, and blob:/data: for thumbnails, exports and fonts.
Closes#11374
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
AI-assisted-by: Claude
* 🎉 Add a context menu to the v3 text editor
Right-clicking while editing a text shape now opens a menu with Cut,
Copy, Paste and Select all, so those actions stop being reachable only
from the keyboard. Cut and Copy are disabled when nothing is selected,
and with no selection the caret first moves to the click position.
The menu is a new `:text` kind of the workspace context menu, so it
inherits the existing positioning, styling and shortcut hints. Its
entries emit events that drive the WASM editor through the same calls
the keyboard path uses, so a menu edit commits exactly like a
keystroke: same undo step, same layer rename.
A secondary click has to leave the text alone until the menu acts on
it. The pointer handlers now ignore it, both the right button and the
macOS Ctrl+Click that reports button 0, so it can no longer start a
drag-selection over what was selected. Pressing the mouse on the menu
would blur the capture surface and end the session, so the menu
cancels the default action of `mousedown` while a text is being
edited.
Paste reads the system clipboard through `navigator.clipboard`, since
a menu click carries no clipboard event.
Closes#10914
AI-assisted-by: claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ✨ Add Playwright tests for the text editor context menu
Eight tests under "Text Editor context menu" in the v3 editor spec.
They load the existing lorem ipsum fixture rather than drawing a shape,
enter edit mode, and drive the menu with a real right click.
They cover the four entries, that the right click keeps the selection
(copy returns the whole text), that a right button drag no longer
replaces it, that moving the caret abandons a staged typography, and
that cut and copy are disabled at a collapsed caret. Typing right after
a menu action also shows the editor kept the focus while the menu was
open.
AI-assisted-by: claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* ♻️ Use a test id instead of a DOM id in the v3 editor spec
AI-assisted-by: claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Thumbnail :uri values arrive from the server over transit, so
media-ids decode to UUID objects instead of strings. The
resolved-uri? helper added in #11563 called
clojure.string/starts-with? on them unconditionally, raising
TypeError: str.lastIndexOf is not a function and crashing the
workspace on frame render. Guard with string? so non-string
URIs fall through to resolve-media, which stringifies them.
AI-assisted-by: muse-spark-1.3-contributor
The management API shared-key-auth middleware was using the standard = operator for key comparison, which is vulnerable to timing attacks. The RPC middleware already uses constant-time comparison via MessageDigest/isEqual.
This change:
- Makes constant-time-eq? public in app.http.middleware
- Updates app.http.management/shared-key-auth to use mw/constant-time-eq?
- Fixes an inconsistency where the nil-key branch returned a 2-arg function
- Adds comprehensive tests for the management shared-key-auth middleware
Closes#11426
AI-assisted-by: qwen3.7-plus
The workspace-thumbnail-by-id ref unconditionally called resolve-media
on thumbnail URIs, which caused a stack overflow when the URI was a
data URI (which can be megabytes long for large images).
Data URIs contain thousands of '/' characters (base64 uses '/' as one
of its 64 characters), causing lambdaisland.uri/join to iterate
thousands of times in remove-dot-segments and overflow the JavaScript
call stack.
Add a resolved-uri? helper that checks if the URI already starts with
'blob:' or 'data:', and skip resolve-media for those cases. Only call
resolve-media when the URI is a plain UUID (media-id from the server).
Closes#11562
AI-assisted-by: qwen3.7-plus
* ✨ Add expires-in option to create-demo-profile
Allow passing an optional expires-in duration when creating a demo profile so its purge is scheduled sooner than the global deletion delay. Values below 5 minutes or above the global delay are rejected with an invalid-expires-in validation error, resolved before any profile is created.
Closes#11573
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Make duration schema decoding total instead of throwing
parse-duration returned by the duration schema decoder threw DateTimeParseException on invalid strings, escaping params validation as a raw error. It now returns the input unchanged so invalid values fail the duration predicate with a clean params-validation error. Closes#11573 AI-assisted-by: muse-spark-1.3-contributor
* 📎 Fix doc version for expires-in change entry
The expires-in change entry was documented under 2.20 but the current version is 2.18.
AI-assisted-by: muse-spark-1.3-contributor
* 🔥 Remove unused comment profiles fan-out in dashboard
The dashboard event retrieve-unread-comment-threads issued one
get-profiles-for-file-comments call per distinct file id and stored
the result in :current-team-comments-users, a state key no one
reads. The dashboard renders each thread author from the thread
payload via get-owner, so the fan-out was N+1 dead work.
Drop the per-file branch and the state write; the event now issues
a single get-unread-comment-threads call.
AI-assisted-by: deepseek-flash
* 🔥 Remove unused file comments users event and state
fetch-file-comments-users had no callers and passed a :team-id
where get-profiles-for-file-comments requires :file-id. Its only
effect was writing :file-comments-users, a viewer state key no
one reads.
Remove the event and the unused state key. The viewer still
loads comment profiles through fetch-profiles.
AI-assisted-by: deepseek-flash
* 🐛 Fix email already used showing a toast instead of an input error
Registering with an email that already exists reported the problem with a
toast while the email input stayed in its valid state, which reads as if the
form was accepted. The message now lands on the email input itself, the same
way the recovery and change password forms report server side errors.
The error is written to :extra-errors because the form mutator recomputes
:errors from the schema on every change, so a value written there is dropped
on the next render.
Fixes#10890
* 📎 Update CHANGES.md to remove email registration fix
Removed a note about a fix for email registration error display.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
---------
Co-authored-by: Shreyash Agare <agareshreyash26@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Skip the limited newsletter report when the public-uri host
belongs to penpot.dev or penpot.app, so the SaaS never sends
subscriber emails to its own telemetry endpoint.
Defer the subscriptions query with delay so it only runs when
a report is actually going to be sent.
AI-assisted-by: muse-spark-1.3-contributor
Delegating plan and review to a subagent (engineer-* or the
builtin general) starts a clean context instead of growing the
main session. Delegating to general keeps the same model.
AI-assisted-by: muse-spark-1.3-contributor
Turn the skills-only file into a full intro to opencode inside
plain devenv: setup, providers, models, opencode.json example,
gh auth, flows, and a skills summary at the end.
Provider, model, and flow sections follow Andrey's own setup
notes; the FAQ stays as a stub for later.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix nested board drop target ignoring ancestor clip bounds
Frame hit-testing (get-frame-by-position, get-frames-by-position and
top-nested-frame) only checked a candidate board's own rectangle,
without accounting for an ancestor board with clip content enabled.
A nested board wider/taller than its clipping ancestor could still be
picked as the drop target in its invisible, clipped-away area, so a
dragged shape would get reparented there and disappear from view.
Add clipped-by-ancestor? to reject a point when it falls outside the
bounds of any ancestor board that has clip content enabled, so the
lookup now stops at the correct visible ancestor instead of
descending into the hidden region.
* 🐛 Fix Ctrl+click deep-select reaching into clipped board area
The clip-aware quadtree query (query-index) filters candidate shapes
by whether they overlap every clip-parent ancestor, but the whole
filter was skipped whenever clip-children? was false. That flag is
turned off while a modifier key (Ctrl/Cmd) is held for deep/penetrate
selection, which was meant to let it reach past boolean/mask clip
boundaries, but it also disabled enforcement for board "Clip content"
ancestors, letting a modifier-held click select a shape sitting in a
board's invisible, clipped-away region.
overlaps-parent? now only relaxes the check for non-frame clip-parents
(bool shapes / mask children) when clip-children? is false; board clip
ancestors are always enforced regardless of the modifier key.
The button let users convert a text shape's inline styles into a
typography asset even when the shape's font-id couldn't be resolved
(e.g. a custom/team font that was removed or isn't loaded), silently
baking a missing font into the new typography asset.
Guard the button on the font actually resolving via
app.main.fonts/fontsdb, in addition to the existing checks (no
typography or token already applied, single selection).
Added e2e coverage for all four conditions that must independently
hide the button: missing font, applied typography asset, multiple
selection with differing values, and applied typography token.
AI-assisted-by: claude-sonnet-5
The project title's max-width was capped via an inline style computed
from the number of thumbnail columns fitting in the grid below it (an
unrelated value, reused only because it happened to be in scope). This
produced an oversized gap between a short/medium title and the file
count, timestamp, and action buttons, and gave long titles an
arbitrary, columns-based truncation point unrelated to the row's
actual available width.
Replace it with a standard flexbox truncate-to-fit: the title sizes to
its own content and sits right next to the info/actions, only
shrinking (and ellipsizing) once the row runs out of room, while the
info/actions never shrink.
* 🐛 Reject duplicate chunk index in chunked uploads
Repeat uploads of the same chunk index each stored a new
object because upload-chunk only checked index bounds. Run the
handler in a transaction, lock the session row and reject an
already-stored index with :duplicate-chunk-index.
Also harden assemble-chunks to require exactly indices 0..n-1
so gaps or duplicates fail instead of assembling a corrupt
file. Covers media, fonts and binfile through the shared
helper.
Closes#11634
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Cap upload chunk size at 30 MiB by default
Chunks were only bounded by the 350 MiB HTTP body limit while the
30 MiB caps applied to the assembled file. Add :upload-max-chunk-size
(default 30 MiB, tunable via env) and reject oversize chunks in
upload-chunk with :validation/:chunk-too-large before anything is
stored. App clients slice at 25/10 MiB, so no frontend change needed.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Fix tx-run! call and storage resolve in upload-chunk
Pass cfg as first arg to db/tx-run!, which expects [system f & params; without it every chunk upload raised invalid system/cfg provided and no chunk was stored, breaking assemble with missing-chunks. Also resolve storage without reuse-conn: put-object! writes to the backend outside any transaction, so reusing the tx connection gives no atomicity. Media, font and storage suites green, lint and format clean. AI-assisted-by: muse-spark-1.3-contributor
Add a body line-length validator to scripts/check-commit. It
fails when a body line exceeds 76 characters, exempting
trailers, URLs, and unbreakable tokens. The 76 limit leaves
room for git log's four-space indent in an 80-column
terminal.
Align the subject limit with the documented 70 characters;
the checker allowed 90 before.
Document the rule as a hard, verifiable requirement in
AGENTS.md, CONTRIBUTING.md, the create-commit skill, and
the workflow memory, and point at scripts/check-commit.
Add tests for the validator and the subject length rule.
AI-assisted-by: deepseek-flash