24151 Commits

Author SHA1 Message Date
Andrey Antukh
b252ab493e 🐛 Declare the nitrate client of the revocation watcher
The params schema of the ::revocation-watcher key named
:app.nitrate/client as a bare keyword. Inside [:map ...] a bare
keyword is not a key but a schema property, so malli resolved it
as a registry lookup. Nothing had registered that keyword, and
compiling the schema raised :malli.core/invalid-schema, which took
down the whole system build instead of one assertion.

Nothing caught it because the assertion only runs when
PENPOT_FLAGS carries enable-backend-asserts, so the suite passed
with the schema broken. With the flag the backend died before a
single test ran.

The client is declared as an explicit pair, the way
auth.oidc/schema:routes-params already does. It is optional
because init-key yields nil when the :admin-console flag is off,
and a required key would fail the assert on a normal boot.

The new test compiles the schema and pins both halves of that
contract, following the pattern already used for the three worker
schemas in backend-tests.worker-test.

AI-assisted-by: space-bunny-free
2026-10-08 14:07:39 +00:00
Andrey Antukh
9880a9471d ♻️ Restore or check for presence message types
Restore the `or` of `=` comparisons for presence message types
instead of the `contains?` set literal. The literal builds a new
set on every forwarded message while `or` over interned keywords
allocates nothing, on a per-message path.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-08 13:08:50 +02:00
Andrey Antukh
1f60c447d3 ♻️ Drive websocket recheck from the relay loop
Fold the subscription re-check into the relay loop itself: wait on
the subscription channel and a one-shot tick channel with `alts!`
and priority on the tick. A single tick channel covers a whole
cadence and message turns reuse it, so the cadence holds under load
without churning timers. Drops `schedule-revalidation` and the
`promesa.exec` require. No behavior change; the focused suite
passes untouched.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-08 13:08:50 +02:00
Andrey Antukh
7d2b420da1 ♻️ Rename close! param and avoid assoc-in
Rename the `start-relay` callback param from `close!` to `close-fn`,
following the no-new-`!` direction and matching the sibling
`on-forward` callback that already carries no `!`.

Replace the two fixed-path `assoc-in` calls in `register-connection`
and `unregister-connection` with `update` plus `assoc`, as asked on
the PR. Both parent maps always exist in the atom, so the behavior
is identical.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-08 13:08:50 +02:00
Andrey Antukh
97193da78e ♻️ Rename websocket recheck param to check-access
Rename the zero-arg permission check from `authorized?` to
`check-access` in `still-authorized?` and `start-relay`, so the
call `(still-authorized? check-access)` reads as running a check
instead of passing a predicate to a predicate. No behavior change:
`?` stays only on the safe yes/no answer.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-08 13:08:50 +02:00
Andrey Antukh
1f96cd2e4c 🐛 Address review findings on websocket revocation
Follow-up to the GHSA-m53j-2766-6jqw fix on issue-12155, addressing
the reviewCode findings F1-F23.

The revalidation task is now cancelled when the relay ends (F1): the
closed channel is what ends it, because every way a subscription can
end closes it. Before, each ended subscription left a task polling the
database every 5 minutes for the life of the process while retaining
the closed connection.

The permission query now filters soft-deleted files, projects and
teams (F2), matching its sibling queries. The websocket re-check
therefore sees what the subscribe handler sees, and `delete-file`,
`delete-project` and the newly announced `delete-team` actually close
file subscriptions.

Team-wide changes publish one event per team instead of one per member
(F4): the watcher resolves the members itself, so a large team cannot
overflow the bounded publish buffers. Both publishers defer through the
existing `db/after-commit` (F3), so the watcher never checks
pre-commit state.

The interval is now genuinely tested (F6): revoking access through a
direct row delete, the way an external change would, plus a negative
control proving the tests fail with the scheduler removed. The
`captured-events` helper forwards keyword args correctly instead of
republishing to a garbage topic (F5), and the registry fixture runs per
test so no relay leaks between tests (F8).

The fail-open policy is pinned in both directions with the interrupt
rethrown (F7), the internal topic is a fixed uuid like every other
topic (F21), the watcher stops on shutdown (F22), the config key sits
with the other HTTP knobs (F19), and the memory no longer claims a
Promesa defect that the repo itself contradicts (F10).

Closes #12155

AI-assisted-by: space-bunny-free
2026-10-08 13:08:50 +02:00
Andrey Antukh
5a25da8d6d 📚 Document websocket subscription revocation
The behavior that made GHSA-m53j-2766-6jqw easy to get wrong is
non-obvious: the connection registry is local to one backend instance
while the message bus is shared, so a revocation has to travel over the
bus to reach the instance that holds the socket. The advisory's own
suggested fix closes the connection from the RPC handler, which only
works in a single-backend deployment.

Records, so the next reader does not have to rediscover it:

- the registry shape and that `state` is shadowed by `::ws/state` in
  every handler, so only the lifecycle hooks touch it
- the two revocation mechanisms (announcement, interval re-check) and
  why the decision is always a fresh permission check
- which mutations announce, and why `delete-share-link` does not
- that the topic and publish helpers live in `app.rpc.notifications`,
  since the command namespaces cannot require the watcher
- three Promesa traps that cost real debugging time here: `alts!` over
  the data channel, the argument order of `ct/diff`, and the singular
  unit keywords of `ct/duration`
- how the tests drive the real msgbus, and that watcher-based tests
  must register their connection

Closes #12155

AI-assisted-by: space-bunny-free
2026-10-08 13:08:50 +02:00
Andrey Antukh
d608f3b898 🐛 Re-check websocket subscriptions on an interval
The announcement path is the primary mechanism, but it cannot see an
access change that fires no command here: Nitrate transferring the
ownership of an organization is an HTTP call to another service, and
rows deleted straight over SQL publish nothing. Both keep a
subscription alive until the connection happens to close.

Each relay now re-checks access every `subscription-revalidation-interval`
(5 minutes by default, overridable) and drops the subscription when it
no longer holds. The default is a code constant in `app.http.websocket`
rather than the `config.clj` default map, matching how session
lifetimes are configured.

The re-check is a scheduled task rather than a timer inside the relay
loop, on purpose: driven by the loop, a steady stream of edits would
push the check away forever and a busy file would never be re-checked
at all.

`start-relay` also replaces the `sp/pipe` of the team subscription and
the ad-hoc `go-loop` of the file one, so both now share the same
forwarding and re-check path.

`still-authorized?` is the one place that fails open: a transient
database error logs and keeps the subscription rather than kicking a
user out of an editor, and the next tick tries again.

Closes #12155

AI-assisted-by: space-bunny-free
2026-10-08 13:08:50 +02:00
Andrey Antukh
e6e6b20cbb 🐛 Announce access changes so subscriptions get revoked
The revocation watcher only helps if the mutations that can take
access away actually announce it. Seven of them now do, after the write
that changed access:

- delete-team-member and leave-team announce the affected member.
- update-team-member-role announces too. A role change never removes
  read on its own, but announcing lets the watcher decide from fresh
  data instead of assuming, which is what keeps a downgrade to viewer
  from being mistaken for a revocation.
- delete-file, delete-project, move-files and move-project announce to
  the whole team. Which profiles had the content open is only known to
  their own connections, and over-announcing is cheap: the watcher
  re-checks and closes nothing for members who keep access.

delete-share-link is deliberately excluded. A share-link visitor never
passes the websocket subscribe check, because `check-read-permissions!`
is called without a share-id and that arity only considers team
membership, so there is no subscription to revoke.

leave-team also gains the notification it never had: it published
nothing at all on the leaver's own topic, so a session that left kept
its editor open with no idea, while a removed member was told.

The topic and the publish helper move to `app.rpc.notifications`, next
to the other bus notifications. The command namespaces cannot require
the watcher itself: `app.http.websocket` already requires these command
namespaces, so that would be a cycle. The watcher keeps the subscription
registry, which is the part it owns.

Closes #12155

AI-assisted-by: space-bunny-free
2026-10-08 13:08:50 +02:00
Andrey Antukh
6060a7140f 🐛 Revoke websocket subscriptions when access is revoked
GHSA-m53j-2766-6jqw. A subscription is authorized once, when it is
opened, and that decision is trusted for the rest of the connection's
life. A member whose access is revoked keeps receiving the live file
diff for as long as their tab stays open.

Closing the connection from the mutation does not work: the registry
is local to one backend instance, while the RPC that revokes access
runs on whichever instance received the request. So the revocation
travels over the message bus instead, which every instance shares, and
each instance re-verifies the subscriptions it owns.

`revalidate-profile-subscriptions` looks the profile up in the new
index, then re-checks every file and team subscription with the same
`has-read-permissions?` predicates that `:subscribe-file` and
`:subscribe-team` authorize with. The event is only the trigger; the
fresh check is the decision, so the watcher keeps no second copy of the
permission rules. That is what makes a downgrade to viewer keep its
subscription, and keeps the read-only access of a non-member
organization owner.

`close-team-subscription` mirrors `close-file-subscription` for team
traffic. Both now forget the subscription instead of leaving a stale
one behind, which makes them idempotent and stops `:pointer-update`
from publishing to a topic the connection no longer relays.

The internal topic is separate from the `uuid/zero` system topic, which
is piped straight to the client. No client can reach it:
`:subscribe-file` and `:subscribe-team` only accept ids that already
passed a permission check.

Closes #12155

AI-assisted-by: space-bunny-free
2026-10-08 13:08:50 +02:00
Andrey Antukh
58178d2383 ♻️ Index websocket connections by profile
The connection registry was a flat map from connection id to
connection data, so finding the connections of one profile meant
walking every connection. `on-connect` and `on-disconnect` also
`swap!`ed a whole-map `assoc`/`dissoc`, which is O(n) per connection
churn.

The registry now holds `:connections` and `:by-profile` in the same
atom, so one `swap!` keeps both consistent and a profile lookup is a
single map access. Both maps are updated together by
`register-connection` / `unregister-connection`; a profile whose last
connection leaves is dropped from the index instead of lingering with
an empty set.

Also extracts `close-file-subscription` out of the
`:unsubscribe-file` handler so a revocation can tear a subscription
down through exactly the same path the client uses: announce
`:leave-file`, close the relay channel, purge the bus. Closing the
channel is what stops the `:subscribe-file` go-loop, since `take!` on
a closed channel returns nil.

Adds `connections-for-profile` and `repl-get-connections-for-profile`
so an operator can inspect a profile's connections from the REPL the
same way as for a file or a team.

The new helpers take no `!` suffix: per `mem:clojure/design-rules` the
default is no `!`, and these are neither `app.db` API nor transient
helpers. Existing `!` names such as `mbus/pub!` are legacy and stay
until their own refactor.

The registry stays local to one backend instance. That is documented
on the var: reaching connections owned by another instance has to go
through the message bus, which is what the revocation watcher will do.

Closes #12155

AI-assisted-by: space-bunny-free
2026-10-08 13:08:50 +02:00
Andrey Antukh
606d68e2b4 🐛 Add failing tests for websocket subscription revocation
GHSA-m53j-2766-6jqw. A websocket subscription checks read permission
once, at subscribe time, then relays every message published to the
topic for the rest of the connection's life. A member whose access is
revoked keeps receiving live file content as long as the tab stays
open, because nothing re-checks the permission.

These tests reproduce the leak against the real message bus, so the
failure is observed end to end rather than assumed:

- revoked-member-stops-receiving-file-changes: after delete-team-member
  the socket still receives the :file-change diff.
- revoked-member-stops-receiving-library-changes: leave-team keeps the
  team subscription alive, leaking :library-change diffs.
- role-downgrade-keeps-revocation-irrelevant: a downgrade to :viewer
  keeps read access, so the subscription must survive it. Guards
  against a fix that closes subscriptions too eagerly.

The two revocation tests fail on the current code; the role test
passes and pins the behavior a fix must not break.

Closes #12155

AI-assisted-by: space-bunny-free
2026-10-08 13:08:50 +02:00
Marina López
28c576e1e6
🐛 Preserve admin console URL path segments (#12196) 2026-10-08 11:38:13 +02:00
Belén Albeza
cb1118f904
🎉 Preserve Penpot's internal formatting when pasting (v3) (#12137)
* 🎉 Copy v3 editor text with its styles in a payload

Copy and cut in the v3 text editor put the styled selection in a
hidden span of text/html. Add its codec and the clean-up for pasting
into another file or team.

Refs #12047

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

* 🎉 Paste text copied in Penpot at the v3 editor caret

Text copied in Penpot keeps its styles when pasted at the caret. The
first paragraph keeps the target's attrs; later ones bring their own.
The context menu paste now shares the keyboard paste path.

Refs #12047

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

* 🎉 Paste text copied in Penpot on the canvas with its styles

Refs #12047

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

* 🎉 Paste copied text shapes at the v3 editor caret with styles

Copied shapes now record their team. Shapes that are not texts paste
nothing into the editor.

Refs #12047

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

* ✨ Replace custom fonts in shapes pasted across teams

Refs #12047

AI-assisted-by: claude-opus-5-5
2026-10-07 18:20:03 +02:00
Miguel de Benito Delgado
f252439d84
♻️ Factor GL context initialization out of canvas init (#11931)
* ♻️ Factor GL context initialization out of canvas init

- fixes setting of gl-context-handle to be after _init so an exception
  does not leave invalid state behind.
- api.init-canvas-context uses a new webgl.init-context!
- this decouples config from init, so we can reuse it in benchmarks
- some helpers move to the webgl ns

AI-assisted-by: muse-spark-1.3, deepseek-flash, glm-5.3

* 🐛 Release acquired GL context when renderer init fails

- Properly release resources if init fails
- Rethrown exceptions with the original cause
- Fix vacuous check in clear-canvas

AI-assisted-by: muse-spark-1.3, deepseek-flash, glm-5.3

* ✨ Test webgl context init and release

AI-assisted-by: muse-spark, glm-5.3

* 💄 Address review comments

* 💄 Drop `!` from some function names
2026-10-07 18:19:34 +02:00
Eva Marco
dcf0b197eb
🐛 Fix inner shadow covering the shape in the SVG renderer (#12141)
* 🐛 Fix inner shadow covering the shape in the SVG renderer

The svg filter components moved to the `*` syntax (#9448), but
filter-entry* still built their props as a raw JS object with
kebab-case keys. Raw JS props skip the kebab to camel conversion, so
the components read nil for filterId and filterIn, and no filter
primitive got its result or in2. With the chain broken, an inner
shadow was blended over the empty flood instead of the shape, hiding
the fill (and a board's children) under the shadow color. Exports
using these filters broke the same way. Pass the props in camelCase.

Closes #11967

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

* ♻️ Build filter entry props with mf/props

Use mf/props instead of a raw JS object so the props keep the
kebab-case keys used across the codebase. The macro converts them to
camelCase at compile time, so there is no runtime cost, and the
comment about the manual conversion is no longer needed.

AI-assisted-by: claude-opus-5-5
2026-10-07 15:45:10 +02:00
Alonso Torres
f62f4024be
Merge pull request #12090 from oraios/mcp-global-plugin
🎉 Add global plugins and enable MCP across workspaces
2026-10-07 15:38:17 +02:00
alonso.torres
9517f2ac94 ♻️ Type the MCP plugin with the repo plugin types
Link the MCP plugin to plugins/libs/plugin-types instead of the
published 1.5.0 package, which lacks PenpotMgmt. The repo types
declare the penpotMgmt global, so the inline import in index.d.ts
goes away and the plugin checks against the API it runs with.

Type Context.management as PenpotMgmt without on/off, so new
management methods need no extra listing.

Mark the background plugin mock in load-plugin.spec.ts as
NonNullable: createPlugin can return undefined, and the build
type-check failed on it.
2026-10-07 15:21:16 +02:00
alonso.torres
a125977fce 🐛 Keep the MCP plugin working without penpotMgmt
Penpot only exposes penpotMgmt to plugins running with global
scope. Without it, the MCP plugin failed on load when registering
its workspacechange listener.

When penpotMgmt is missing, the plugin now builds the workspace
metadata from the open file and skips the listener. execute_code
receives penpotMgmt as null and waits for layout updates as before.
The tool description and instructions tell agents that only the
open file is available when penpotMgmt is null.
2026-10-07 14:58:32 +02:00
alonso.torres
dfff188409 ✨ Ask users to allow global plugins on install
A plugin with "scope": "global" keeps running across the dashboard
and files and can find and open the user's projects and files. Any
manifest could ask for this without the user being told.

Add the allow:global permission. The plugin registry adds it to
every manifest with global scope, so the install dialog lists it,
and a plugin that becomes global in a later version shows the
permissions update dialog again. The permission is valid in the
shared schema, which the backend checks when saving, and in the
runtime manifest schema. The built-in MCP plugin declares it too.

Document the scope property and the new permission for plugin
developers.
2026-10-07 14:58:32 +02:00
alonso.torres
a381761aa4 ✨ Add project and file creation to penpotMgmt
Add penpotMgmt.createProject({name, teamId?}) and
penpotMgmt.createFile({projectId, name}). Both require content:write,
trim the name and require 1 to 250 characters, and return the same
metadata as listProjects and listFiles without changing the current
view. When the new project or file belongs to the current team, the
dashboard state is updated so it shows up right away.

listProjects, listFiles and the creation methods share the summary
builders, so fileCount is now 0 instead of missing for new projects.

Update the plugin types, the MCP API docs and the MCP instructions.
2026-10-07 14:58:32 +02:00
alonso.torres
cf1fbb4d7c 🐛 Close plugins whose load is cancelled before they run
Unloading or replacing a plugin while its code was still being
fetched could leave a late instance running, or block reopening a
plugin that closed during startup.

Each load now carries an AbortController. Unloading, a newer load
of the same plugin or logout aborts it, and the plugin manager
closes itself on abort, also while it fetches its code.
createPlugin returns nothing for a plugin closed before its
sandbox exists, and failed loads clear their pending state so the
plugin can be loaded again.
2026-10-07 14:58:32 +02:00
alonso.torres
c4d2ca9cd1 ✨ Move the dashboard MCP menu to the page headers
Show the MCP menu next to the layout toggle in the header of the
projects, files and deleted pages, instead of in the sidebar. It
uses the secondary button style there and a visible dot colour
while disconnected.

Clicking the MCP button now toggles the menu instead of only
opening it. Dashboard events report "dashboard:header" as their
origin.
2026-10-07 14:58:32 +02:00
Dominik Jain
036f59266b 🐛 Discard cancelled plugin loads after unloading
Cause:
- Disabling MCP during script loading could leave a late plugin instance
  attached to a stopped controller, blocking connection after re-enabling.

Fix:
- Track pending loads and discard instances cancelled by unload.
- Prevent duplicate global loads and preserve newer replacement instances.
- Clear pending state after failures so a subsequent load can retry.

Validation:
- Pass 84 runtime tests, the runtime build, lint and formatting checks.
- Reproduce the race with a delayed script in the browser, then verify
  re-enabling restores the session ID, themed indicator and MCP execution.

AI-assisted-by: gpt-6
Signed-off-by: Dominik Jain <dominik.jain@oraios-ai.de>
2026-10-07 14:58:32 +02:00
Dominik Jain
2bb6cd557e ✨ Add project and file discovery to penpotMgmt
Discovery:
- Add listProjects and project-scoped listFiles with content:read checks.
- Return metadata from existing RPCs in dashboard and workspace contexts.
- Reject invalid IDs and preserve backend access errors.

Documentation:
- Keep usage in API type comments and include regenerated MCP API docs.
- Remove README implementation details and consolidate the changelog.
- Add usage information to the MCP server's initial instructions

Validation:
- Pass 8 frontend tests, 81 runtime tests and the runtime build.
- Verify both APIs through live MCP calls in dashboard and workspace.
- Pass type, lint, formatting and diff checks.

AI-assisted-by: gpt-6
Signed-off-by: Dominik Jain <dominik.jain@oraios-ai.de>
2026-10-07 14:58:32 +02:00
Dominik Jain
54cce66c13 ✨ Share the MCP connection menu with the dashboard
Connection status:
- Replace the dashboard's action-only link with an explicit connected
  label and indicator, using the theme's existing accent colour.
- Reflect the actual connection state independently of connection intent.

Shared controls:
- Extract the toolbar's MCP menu and styles into a shared component used
  by both the dashboard and workspace.
- Provide connection status, session ID, copy, connect and disconnect
  actions in both locations, preserving hover and keyboard behaviour.
- Keep dashboard and toolbar event origins distinct and update the
  dashboard connection instructions.

Validation:
- Confirm dashboard disconnect/reconnect, menu opening, keyboard
  navigation and Escape dismissal in the running app.
- Pass live compilation, targeted ClojureScript and SCSS lint,
  formatting checks and git diff checks.

AI-assisted-by: gpt-6
Signed-off-by: Dominik Jain <dominik.jain@oraios-ai.de>
2026-10-07 14:58:32 +02:00
Dominik Jain
be17fabc6a 🎉 Introduce global plugins and move MCP to application scope
Global plugin lifecycle:
- Add optional manifest scope "global" for plugins that remain active
  across dashboard and workspace navigation within one browser tab.
  Existing plugins retain workspace scope by default.
- Preserve global sandboxes when a workspace ends or another plugin
  loads, including when that other plugin fails to initialize.
- Avoid duplicate instances of an already-running global plugin.
- Close global plugins on explicit closure or logout, with idempotent
  cleanup of listeners, timers and UI resources. Global scope does not
  imply automatic startup or persistence across a browser reload.

Plugin API and navigation:
- Keep a permanently live penpot facade whose getters follow the current
  workspace, while user, theme and UI facilities remain available on
  the dashboard.
- Expose penpotMgmt to global plugins with workspace metadata,
  none/loading/ready status and workspacechange subscriptions.
- Add awaitable openFile(fileId, {teamId}) navigation in the current tab,
  defaulting to the current team and resolving only once the requested
  file and its active page are ready.
- Reject invalid identifiers, failed or superseded navigation and waits
  exceeding 30 seconds; release navigation watchers on completion.
- Guard workspace-bound operations while no workspace is ready and
  return null from root, currentFile and currentPage in that state.
- Require content:read for the initial management API. Callers must
  reacquire file, page and shape references after changing files.

MCP lifecycle and connection controls:
- Make MCP a global plugin and initialize its integrated controller per
  authenticated application session instead of per workspace.
- Load the enabled integration with a valid token on the dashboard as
  well as in a file, retaining its existing hidden-iframe implementation.
- Keep enabling and connecting separate: an enabled plugin starts idle,
  and each tab connects explicitly through dashboard or workspace
  controls.
- Preserve the iframe, WebSocket, heartbeat and reconnect machinery
  during navigation; disconnect on request and close on disable/logout.

MCP sessions and execution:
- Derive the short MCP session ID solely from currentUser.sessionId,
  excluding the file ID. Retain the SHA-256-based, 50-bit Base32 encoding
  to produce a stable, copyable 10-character identifier.
- Allow sessions without an open file and update workspace metadata over
  the existing connection without replacing its routing identity.
- Refresh context after the connection handshake so initialization does
  not leave stale file metadata.
- Expose penpotMgmt to execute_code, preserve execution storage across
  navigation and skip layout waits when no workspace is ready.
- Update API types, tool guidance, documentation and the changelog.

Validation:
- Cover authenticated startup, workspace survival, logout cleanup,
  readiness, navigation failure, duplicate loads and session routing.
- Verify that short IDs remain stable across files and reconnects and
  that disconnect cancels pending asynchronous ID initialization.
- Pass 16 focused frontend tests, 73 runtime tests, 30 MCP plugin tests,
  builds, TypeScript checks and frontend/runtime lint.
- Pass all 73 MCP server tests with --test-force-exit; the normal runner
  still stays alive after assertions finish.
- Verify a live dashboard-to-file-to-dashboard round trip without a
  document or iframe reload, preserving facade identity and storage.

AI-assisted-by: gpt-6
2026-10-07 14:58:32 +02:00
Alejandro Alonso
31000c3055
⚡ Enable rustc wasm SIMD for render-wasm builds (#12163)
Set RUSTFLAGS target-feature=+simd128 on the emscripten
target so our Rust code can autovec; Skia SIMD remains
via the -simd prebuild and EMCC -msimd128.
2026-10-07 14:52:46 +02:00
David Barragán Merino
6473dc85ab 👷 Notify new devenv images in penpotdev
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-10-07 14:11:21 +02:00
Andrey Antukh
c4c1c5a955 🐛 Fix media-processor upload-storage tests
Update media-processor dependencies (multer 2.4, vitest 5
and others) and fix mockFile to emit Buffer chunks so
multer memory storage Buffer.concat works. String chunks
caused TypeError and 5s timeouts in two tests.

AI-assisted-by: muse-spark-1.3-contributor
2026-10-07 11:52:46 +00:00
David Barragán Merino
27ccb32ea1 👷 Fix the devenv image build workflow
AI-assisted-by: Claude
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-10-07 13:17:13 +02:00
Marina López
d0cf399807
🐛 Show trial status on subscription page (#12158) 2026-10-07 13:14:58 +02:00
Marina López
65b248eec3
🐛 Preserve team ID in Nitrate membership URL (#12157) 2026-10-07 13:08:02 +02:00
David Barragán Merino
edd500302c ⬆️ Update ImageMagick to 7.1.2-32
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-10-07 13:02:27 +02:00
Belén Albeza
1d7c775f72
🐛 Fix not setting mimetype in image fills (#12136) 2026-10-07 12:26:45 +02:00
Alejandro Alonso
cc5f2f57ad
⬆️ Upgrade skia-safe to 0.153.3 with wasm SIMD (#12148)
Point SKIA_BINARIES_URL at the 0.153.3 simd prebuild and adapt
fonts, path stroke expansion, and gradients to the new API.
2026-10-07 12:08:33 +02:00
Andrey Antukh
9abcfebf60
✨ Run binfile import and export as durable jobs (#12056)
* ✨ Run binfile import and export as durable jobs

Import and export of `.penpot` files become durable jobs: two commands
create them and answer at once with a job id the caller follows, while
a worker of the `binfile` queue does the work. Each job keeps its own
ledger (owner, params frozen at creation, result, progress history and
an expiry), and the legacy commands and their SSE keep working.

Why: both operations ran inside the request. A large import held it
open for minutes, progress only existed while the caller kept the
stream open, and a client that went away left work nobody recorded,
retried or could cancel. The unified `job` substrate already dispatched,
leased, retried and logged events, but it had no user-facing ledger.

How:
- The substrate gains the pieces a user-facing job needs: optional
  job-def metadata (family, resource-role), an expiry on submit, the
  owner in the runner context, and job resources in their own bucket,
  written with the profile of the job and never deduplicated.
- One generic command per family: the caller names the job and sends its
  business params, the registry checks the family, the job-def decodes
  and validates, and permission and quota are checked first.
- Progress taps of the core become milestones of the job: a shared stage
  vocabulary with self-contained counters, throttled heartbeats, and an
  interrupt that stops the run inside its own transaction on cancel.
- Cancel reaches pending and running jobs from both modals, and closing
  a modal mid-run cancels the job it was following.
- The export re-checks the read permission at run time and answers with
  the package descriptor; the import re-checks the edition permission,
  runs the core in a transaction so a cancellation rolls it back, and
  releases the consumed package and its temporary copy.

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

* ♻️ Handle externally-cancelled binfile jobs end to end

The `cancelled` outcome of a job nobody followed no longer hangs its
screens: the data layers emit an explicit terminal message for it.

- The export emits `{:cancelled true}` per file and the modal paints a
  neutral cancelled row instead of success; a message without an
  artifact never reaches the download branch.
- The import closes every entry with a dedicated cancelled message
  through the existing error rendering, so the wizard completes.
- Both flows are pinned by data-layer tests, including the red state
  they replace.

Also in this change: the progress-contract comment of the management
API, early business-params validation in create-import-job (before
assembling the upload), a `release-resource` helper without fabricated
job ids, a note on the heartbeat write cost, and a memory cleanup.

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

* ♻️ Pre-translate the cancelled import message

The cancelled entry of the import wizard carried its message as a key
for the UI to translate, which needed a rehash marker comment. Like
its neighboring errors, it now translates eagerly in the data layer:
the literal call keeps rehash clean with no marker, and the tests
assert through the same call instead of the raw key.

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

* ♻️ Move cancelled strings under generic labels keys

The dashboard-specific cancelled key never shipped: before use it moves
under `labels.*` next to the generic strings both flows already use,
with Spanish translations following the established wording.

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

* ✨ Add minor improvements

* ♻️ Rename export job command to create-export-binfile-job

Export now has one command per job type, the shape the :export-assets
command needs: create-export-binfile-job freezes exactly the
:export-binfile job, so :name no longer travels in the body and only
:params does. Import keeps the generic envelope.

submit-job reads the queue from the job-def metadata ::jobs/queue-name
(falling back to :binfile), read by the command and never by the
substrate, so a job-def can route its work to another queue without a
change in the command.

The rename reaches the frontend and the tests: the binfile export is
asked for under its own name, and the exporter service keeps
create-export-job.

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

* ✨ Polish the import and export status rows

The status row of a file already carries what the file is doing, so the
generic "Uploading file…"/"Downloading file…" lines are gone: the
import wizard drops its own (the export one went with the jobs
refactor), along with the now-unused style.

In the export, every file is queued up front, even the ones whose job
is created only after the previous one is over, and a file stays queued
until its first milestone: a worker picking the job up no longer ends
the queue. The queued text is a step smaller than the milestone.

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

* 🐛 Show the cancelled export and fix the import error

The cancelled export was unreachable: mark-file-cancelled sets :loading
false and the label lived inside the loading branch, so a cancelled
file only painted a neutral notification. The terminal state now
carries the label, and the export flags lose the `?` suffix
(predicates keep it; a data prop is not a question).

The import wizard rendered (:error entry) through tr, but every
producer writes user-facing text (a hint or a translated label), never
a key: it renders the text as-is now, and the dynamic-key ignores and
the stale comment are gone.

The total-text-limit comment no longer references the 200MiB it
replaced; the value is 500MiB in develop already.

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

* ♻️ Reflect the real job params in the creation commands

The creation commands declared their params as an open empty map, so the
RPC contract and the generated docs said nothing about them. Each command
now imports the schema from the job namespace
(`export-binfile/schema:params`, `import-binfile/schema:create-params`),
and the RPC validates the business params before the command runs. The
job-def still owns the params and re-validates the assembled ones at run
time. The commands no longer decode the params again: the RPC did it.

Import joins export as one command per job type: `create-import-job`
becomes `create-import-binfile-job`, its body no longer carries `:name`
and the job-def is resolved by name like export's. Its creation schema
keeps the version optional, because the command reads it from the package
header when the caller does not say it, and fills the manifest metadata
kept for audit.

A version out of range is now refused by the schema (`:params-validation`)
instead of the command's own `:unsupported-version` check, which is gone.

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

* 🐛 Stop the test system from running job runners

The test system is main/system-config plus main/worker-config with a
few components dissoc'ed. The runners of default, webhook and cron were
listed by hand; the binfile runner added with the durable binfile jobs
was not, so a real penpot/job-runner/binfile/0 thread ran during the
whole suite.

That runner races the tests that drive the loop by hand (run-batch plus
run-worker-loop): it takes the job the test just dispatched and
completes it, and the test then reads a state it did not produce. That
is the reported flake of binfile-jobs-integration-test, whose middle
assertion waits for a runner of another queue to not take the job. It
only reproduced in CI: in the devenv PENPOT_TENANT makes the tenant
baked into main/worker-config differ from the one the test builds, so
the two used different queue keys locally.

Drop every [<profile> :app.worker/runner] by key shape instead of
listing the known queues, so a new queue cannot bring the race back.

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

* ✨ Upload the package one chunk at a time and as a percentage

The upload milestone counts chunks, not units of work, so the row wrote
"Uploading the package 3/9" over a file the user had uploaded once: it
read as if nine packages were on their way. Write the stage as a
percentage of the file instead ("Uploading file… (33%)"), and make the
English text of the stage say file, not package.

The chunks also go one at a time now (concat-all instead of merge-all
with two in flight), so the percentage moves in a straight line instead
of jumping. upload-blob-chunked is the generic uploader, so media and
font uploads become sequential too.

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

* 🌐 Complete the Spanish catalogue

es.po was 25 keys short of en.po: the 15 that the durable binfile jobs
introduced (14 jobs.progress.stage.* and labels.queued) plus 10 gaps
that predate them (errors.connection-error, errors.save-retrying, the
find shortcuts, workspace.header.retrying,
workspace.tokens.stroke-width and the mcp session labels).

sync -l es then sorts the entries, syncs the #: comments from en and
drops the 7 dashboard.import.progress.* keys en no longer has. The
msgid sets of en and es are now identical (0 missing, 0 extra).

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

* ♻️ Move the job status keys into the jobs namespace

The three status words the durable import and export rows render were
filed under labels.*, next to the generic labels bucket, while their
progress stages live under jobs.progress.stage.*: the strings of one
feature sat in two places.

Rename them so the whole job vocabulary is one namespace:

  labels.queued           -> jobs.queued
  labels.export-cancelled -> jobs.export-cancelled
  labels.import-cancelled -> jobs.import-cancelled

No other locale has these keys yet, so the rename costs no translation.
The convention (a key names the feature that renders it; labels.* holds
the generic reusable words) is written down in the frontend/translations
memory.

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

* 🔥 Remove the translation keys nothing references

Six keys of en.po (and of es.po, which now mirrors it) have no (tr ...)
call site left in frontend/src or common/src, and no literal reference
either:

  labels.uploading-file    a duplicate of jobs.progress.stage.upload
                           since the upload stage says "Uploading file…"
  labels.downloading-file  dead since the durable jobs replaced the old
                           import and export progress rows
  inspect.attributes.stroke.alignment.center
  inspect.attributes.stroke.alignment.inner
  inspect.attributes.stroke.alignment.outer
  workspace.toolbar.mcp-connect-here

The first two are leftovers of the durable binfile jobs, the other four
predate them. Both catalogues keep an identical msgid set afterwards.

AI-assisted-by: deepseek-v4.1-flash
2026-10-07 11:52:48 +02:00
Alejandro Alonso
accea1abde
⚡ Enable wasm SIMD for Skia render-wasm builds (#12145)
Link with -msimd128 and point SKIA_BINARIES_URL at the pdf+simd prebuilt archive.
2026-10-07 11:30:40 +02:00
Danny Shirely
051f01d559
🐛 Keep per-side stroke widths out of inspect geometry (#12055)
Remove per-side stroke widths from both Inspect geometry panels so
Stroke is the only section that lists them. Keep dimensions, position,
corner radii, and transforms in Size and position.

Add rendered-component regressions for boards, rectangles, multiple
selection, uniform strokes, and CSS declarations.

Closes #12026

AI-assisted-by: GPT-6

Signed-off-by: Danial Shirali <danialshirali16@gmail.com>
Signed-off-by: Eva Marco <eva.marco@kaleidos.net>
Co-authored-by: Eva Marco <eva.marco@kaleidos.net>
2026-10-07 11:18:46 +02:00
Marina López
a9bf53adbf
Revert "🐛 Preserve path segments when joining backend URIs (#12152)" (#12156)
This reverts commit 2a6e92a7d9a5520365276f7b9a6ae59d478159ee.
2026-10-07 11:10:02 +02:00
Marina López
2a6e92a7d9
🐛 Preserve path segments when joining backend URIs (#12152) 2026-10-07 10:45:17 +02:00
Andrey Antukh
a5df69f60f Merge remote-tracking branch 'origin/staging' into develop 2026-10-07 10:25:01 +02:00
Andrey Antukh
7f0aa4f38e Merge remote-tracking branch 'origin/main' into staging 2026-10-07 10:23:37 +02:00
Andrey Antukh
b2d688a04c 💄 Stringify ids in comp-processors log props
Wrap every id prop in the comp-processors warn logs
with str so the log shows plain text instead of
the #uuid reader form. Also use :hint instead of
:msg and lowercase text, as the rest of the system
does. No logic changes, logging only.

AI-assisted-by: muse-spark-1.3
2026-10-07 08:17:33 +00:00
Eva Marco
2f47be6c15
🐛 Show the size badge on paths and straight lines (#12066)
* 🐛 Show the size badge on paths and straight lines

The badge was hidden when the shape was smaller than the badge in
either side. Flat paths have a zero width or height, so they never
showed it, and straight two-point paths hid it on purpose.

Paths now only compare their longest side with the badge width. A
straight line compares its length, and the badge runs along the
line, rotated with it and kept upright. Near the bottom of the
viewport the badge moves to the other side of the line.

The badge now reads the size from the shape :selrect, like the
measures inputs, so a rotated path shows the same values in both
places. The three badge branches share a single render block.

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

* ♻️ Extract the size badge layout and add tests for it

Move the badge geometry out of selection-size-badge* into the pure
size-badge-layout function, so it can be tested without rendering.
The component now only picks the color and draws the result.

Add unit tests for rects, flat paths and straight lines: when the
badge hides, where it sits, how it rotates and how it moves when it
reaches the bottom of the viewport. Keep the line rotation between
-90 and 90 degrees instead of returning 360 for lines drawn leftward.

The e2e test still expected straight lines to hide the badge. It now
checks that the badge shows and follows the line angle.

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

* ✨ Show the length of straight lines in the size badge

A straight two-point path is measured by its length, not by the box
around it, so its size badge now shows the length with two decimals at
most instead of width x height. Other shapes and paths keep width x
height.

The shorter text makes the badge narrower, so it also shows on shorter
lines; the rule is the same, the badge needs the line to be at least as
long as the badge.

Relates to #12064

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

* 🐛 Hide the path size badge when the path is lower than it

Paths other than a straight line only hid the size badge when their
longest side was narrower than it, so a wide but low path, or a narrow
but tall one, still showed it. Flat paths no longer need that special
case, since straight lines get their own length badge. Use the same
rule as any other shape: hide the badge when either the width or the
height of the path is smaller than the badge.

AI-assisted-by: claude-opus-5-5
2026-10-07 09:18:57 +02:00
Andrey Antukh
e7041234f3 📎 Set library (sdk) version to 1.2.0 2026-10-07 08:49:32 +02:00
Andrey Antukh
fc8a126048 Merge remote-tracking branch 'origin/staging' into develop 2026-10-06 18:59:56 +02:00
Andrey Antukh
84f068aca7 Merge remote-tracking branch 'origin/main' into staging 2026-10-06 18:59:36 +02:00
Andrey Antukh
5b02e4d898 ✨ Add find-project-anomalies skill and helper
Check a milestone against the Main project board with
scripts/project-anomalies.py (check writes
tmp/<MILESTONE>-ANOMALIES.md): open issues with merged PRs,
milestone mismatches either way, needs triage labels and
unassigned non-community issues. Outsiders resolve in bulk;
only a missing milestone auto-fixes, on confirmation.

Teach scripts/gh.py batched issue lookup (issue subcommand),
milestone on prs output, plus assignees and projects fields,
and retry transient HTTP 504s.

AI-assisted-by: muse-spark-1.3
2026-10-06 16:58:08 +00:00
Andrey Antukh
4bdded86be ♻️ Extract changelog checks into scripts/changelog.py
Move the inline programs out of the update-changelog skill into
scripts/changelog.py (check-merged, cross-ref, report) and shrink
the skill to the workflow alone.

Teach scripts/gh.py to retry transient HTTP 504s, resolve lookups
in batches of 50 (new issue subcommand, milestone on prs output),
and report 💥 entries without the breaking change label.

Add the nine missing 2.17.0 changelog entries found while
trying the new flow.

AI-assisted-by: muse-spark-1.3
2026-10-06 16:26:37 +00:00