The schema:props schema still included :plugins as a valid key,
so the closed-map validation passed instead of rejecting it.
The system-managed-props denylist only silently stripped the key
after validation, never raised an error.
Derive schema:props-writeable from schema:props using sm/dissoc-key
to exclude system-managed keys, and use it in the update-profile-props
params schema. The original schema:props remains intact for get-profile
result validation.
AI-assisted-by: mimo-v2.5
When the fetched manifest differs from the stored manifest
(code, name, etc.), show the permissions dialog instead of
silently updating — preventing execution of tampered/injected
plugins.
On fetch error, show a warning notification instead of loading
with the old manifest.
Bundled plugins (no URL) skip validation as they are trusted
with no remote source.
Completes the 3-layer defense for T3-N1-02: (1) closed
permission schema, (2) dedicated RPC methods, (3) integrity
validation on open.
AI-assisted-by: qwen3.7-plus
Add `add-profile-plugin` and `remove-profile-plugin` RPC methods for
atomic plugin registry operations, preventing manipulation via the
broader `update-profile-props` endpoint.
- Close the `:plugins` field in `update-profile-props` schema to
eliminate the mass assignment attack vector for plugin data.
- Define `valid-permissions` and a closed `schema:permissions` enum to
restrict plugin permissions to known values.
- Migrate the frontend to use the new granular RPC methods with
optimistic updates and rollback on failure.
- Add comprehensive backend tests covering valid/invalid permissions,
updates, removal, and rejection via old endpoint.
AI-assisted-by: qwen3.7-plus
Pasting text could throw "Unknown node type" and lose the paste. The
insertion paths assume the caret sits on a text node or a <br>, but the
browser can report it on a container element (the offset being a child
index, common in Firefox) or, for an empty text shape that was just
focused, on nothing at all: selectAll() returned early without ever
setting a selection.
Add resolveTextNodePosition(), which walks a (node, offset) pair down to
the addressed text node or line break and returns null instead of
throwing when it cannot. The selection controller normalizes the caret
with it before inserting text or a pasted fragment, and selectAll() now
collapses on the line break of an empty editor so the caret is always
usable.
Closes#11149
AI-assisted-by: longcat-2.0-free
Add media type validation to upload-tempfile and upload-org-logo
management endpoints. Both stored user-supplied mtype without
checking against an allowlist. Only image types and PDF are
permitted. Non-public bucket assets now also carry
Content-Disposition: attachment to prevent inline rendering.
AI-assisted-by: mimo-v2.5-pro
Make sd-token-uuid nil-safe when accessing .original.id to prevent
crashes when StyleDictionary emits group nodes alongside real tokens.
Group nodes have an original object but no id property, causing
undefined is not an object errors during interactive token resolution
in the edit modal.
Closes#11143
AI-assisted-by: qwen3.7-plus
The sidebar measures panel numeric inputs (X, Y, width, height,
rotation) emitted one full apply-modifiers commit per DOM event with
no throttle: every arrow key-repeat, wheel tick and scrub pointermove
became update-positions / update-dimensions / increase-rotation. A
sustained gesture starved the React renderer and crashed the
workspace with error #185 (Maximum update depth exceeded).
Coalesce those bursts at the data layer (potok), following the
update-position-data debounce pattern in texts.cljs:
- update-positions is now burst-coalesced in place (its only caller
is the measures panel); new update-dimensions-coalesced and
increase-rotation-coalesced variants are used by the measures
panel, while the immediate events keep serving plugins, variants
and token application (including the delta? rotation path).
- The first event of a burst commits immediately (leading edge, so
single edits stay synchronous); further ticks commit at most once
per 50 ms (throttle); a trailing debounced flush guarantees the
exact final value lands. All payloads are absolute values, so
keeping the latest queued value per shape/attribute is lossless.
- Pending payloads are drained atomically and stale shape ids
(deleted mid-burst) are skipped. The drain stream lives until the
workspace is finalized, so bursts reuse a single subscription.
- Fewer commits per burst also means fewer undo entries; scrub drags
still produce a single entry via the input's outer transaction.
Tests: new frontend-tests.logic.sidebar-transform-coalescing-test (8
tests, legacy SVG and WASM renderer branches) guards the invariant
that a 20-event burst commits the exact final value in a handful of
commits. The previously unregistered update-position-test is wired
into the runner with WASM mock fixtures (it fails in full-suite
context without them due to a pre-existing global mock-state issue).
AI-assisted-by: kimi-k3
Add role-ceiling check to create-team-invitations and
update-team-invitation-role methods. These RPC methods allowed
team admins to grant or elevate invitations to :owner role,
bypassing the protection that exists in update-team-member-role.
The fix replicates the existing check from update-team-member-role:
reject promotion to :owner when the caller is not an owner.
Closes#11098
AI-assisted-by: qwen3.7-plus
The validate-url-allows-public-{https,http} tests relied on real DNS
resolution of example.com, which fails in containers without public
DNS access. Mock resolve-host to return a known public IP, consistent
with the pattern used by other tests in the same file.
AI-assisted-by: mimo-v2.5-pro
Apply climit with 4 global permits and 1 per-profile permit (queue 2)
to prevent connection pool exhaustion from concurrent imports. Each
import holds a DB connection for its entire duration with idle
transaction timeout disabled, so unbounded concurrency could exhaust
the pool (default 60 connections).
AI-assisted-by: mimo-v2.5-pro
The MCP workflow was named "MCP CI" while every other tests-*.yml
workflow uses the "CI: <Component>" pattern. Rename it to "CI: MCP"
for consistency in the GitHub Actions listing.
The global `proxy_set_header Host $http_host;` forwarded the client-facing
Host to internal proxy_pass calls (backend/exporter), breaking mTLS routing
in service-mesh setups (e.g. Istio STRICT mode), which match outbound
requests to a cluster based on Host/:authority.
Explicitly set `Host $proxy_host` on /api, /assets, /api/export, /readyz
and /ws/notifications so these calls always target the correct internal
service host, independent of the client's original Host header.
Fixes#10835
Signed-off-by: Sebastien MALOT <sebastien.malot@pm.gouv.fr>
Co-authored-by: Sebastien MALOT <sebastien.malot@pm.gouv.fr>
Enable Playwright's JSON reporter alongside `list` and publish a
summary of flaky tests to the job step summary. The JSON report is
kept as an artifact for 30 days so flakiness rates can be aggregated
over time.
CI already runs with `retries: 2`, so unstable tests have been passing
silently on retry. This only surfaces what the suite already absorbs;
no test behaviour changes.
The reporter in `frontend/scripts/test-e2e` becomes overridable via
`PLAYWRIGHT_REPORTER` so the local developer default stays untouched.
The comments layer lives in the viewport overlays, which are absolutely
positioned above the canvas, and the container itself carries a high
z-index. A comment bubble panned into the ruler bars therefore painted
on top of them, covering the ticks and numbers.
Clip the comments container to the area outside the ruler bars while
the rulers are visible, the same thing the `clip-handlers` clip path
already does so the selection handlers stay off the rulers. Clipping
only the comments container leaves the text editing overlay, which
shares the viewport overlays, untouched.
Fixes#11163.
Signed-off-by: Filip Sajdak <filip.sajdak@siili.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The generated /etc/nginx/overrides/server.d/mcp-locations.conf used a
plain proxy_pass target (e.g. `proxy_pass http://penpot-mcp:4402;`)
where $PENPOT_MCP_URI/$PENPOT_MCP_URI_WS are shell variables substituted
once by envsubst in nginx-entrypoint.sh at container startup, not nginx
variables. nginx resolves a literal proxy_pass hostname once when the
config loads and never re-checks it, so the existing
`resolver 127.0.0.11 valid=10s;` directive in
overrides/http.d/resolvers.conf has no effect on these three locations
- it only applies to nginx variables evaluated per-request.
In multi-container deployments where the penpot-mcp container restarts
or is recreated independently of penpot-frontend (image update, OOM,
orchestrator reschedule), it gets a new IP from Docker's/the
orchestrator's DNS, and the frontend's nginx keeps forwarding to the
old, now-dead address until penpot-frontend itself is restarted. This
surfaces to users as `wss://<host>/mcp/ws` failing to connect from the
browser after enabling the MCP plugin, with
`connect() failed (111: Connection refused)` in the frontend's nginx
logs.
Route each location through a `set $var ...; proxy_pass $var;` pair so
proxy_pass evaluates a real nginx variable, letting the pre-existing
resolver directive re-resolve penpot-mcp within its 10s TTL instead of
caching the address for the container's lifetime.
For /mcp/stream and /mcp/sse, the set value also appends
$is_args$args explicitly: when proxy_pass targets a variable AND that
variable's value includes a URI/path component, nginx does not
automatically forward the original request's query string the way it
does for a static proxy_pass target - it must be appended by hand, or
the userToken query parameter used for multi-user authentication is
silently dropped before reaching the MCP server. /mcp/ws has no path
component in its target so it isn't affected by this and needed no
such change.
Verified locally: force-recreated the penpot-mcp container onto a
different IP while leaving penpot-frontend untouched; the /mcp/ws
WebSocket upgrade kept returning 101 Switching Protocols throughout,
both immediately and after the resolver's TTL window. Separately
verified /mcp/stream: a POST with ?userToken=... now shows up
server-side as userTokenFp=<redacted first 8 chars> instead of <none>,
and an actual MCP client (Claude Code) using this proxy can now call
authenticated tools like execute_code successfully.
Signed-off-by: Jules LaPrairie <jules@lucidbox.ca>
Add `project-id` to the guard condition in `use-plugin-register`'s layout effect so the plugin "Try out" flow waits until projects have loaded.
Previously, only `plugin-url` was checked, which allowed the fetch to fire
before projects were available, sending a nil `project-id` and causing a 400
validation error from the backend.
AI-assisted-by: mimo-v2.5-pro
When a profile is deleted, only the current session was being
invalidated. Other active sessions on different devices remained
functional until the background cleanup task completed.
Add session/invalidate-all helper that deletes all sessions for
a profile by profile_id, and call it from delete-profile before
the response transform. This ensures immediate access revocation
across all devices when an account is deleted.
Closes#11114
AI-assisted-by: qwen3.7-plus
The fetch-manifest function previously had no timeout, causing the
plugin installation flow to hang indefinitely if the server accepted
the connection but never completed the response.
Added a 15-second timeout using rx/timeout to abort the request
automatically.
Closes#11119
AI-assisted-by: qwen3.7-plus