* 🐛 Restrict webhook edit/delete to team members only
Remove the creator-id fallback from get-webhooks-permissions.
Previously, the webhook creator could always edit/delete their
webhook even after being removed from the team. Now can-edit
comes from team role only — removed users get :not-found.
Webhooks are NOT deleted on member removal; the team owns them
and team admins/owners manage them.
AI-assisted-by: mimo-v2.5-pro
* 🐛 Restrict webhook creation to team editors
Use team role check (check-edition-permissions!) for create-webhook
instead of the custom check that allowed any team member to create
webhooks via creator-id self-match override.
AI-assisted-by: mimo-v2.5-pro
Add check-library-team-ownership! helper that verifies both the file
and library share the same team before creating or modifying library
relations. This prevents cross-team library injection where a user
with edit permissions on files in different teams could link them
across team boundaries.
Applied to link-file-to-library, unlink-file-from-library, and
update-file-library-sync-status handlers.
AI-assisted-by: mimo-v2.5
Add :closed true to schema:import-binfile to reject unknown keys.
Remove file-id from handler destructuring, config binding, and audit
props to prevent specifying a target file on import.
AI-assisted-by: mimo-v2.5-pro
Prevent email bombing attacks on the send-user-feedback endpoint by
limiting the error-report field to 1MiB and adding climit rate limits:
by-profile (1 permit, queue 3) and global (4 permits), configured in
climit.edn. Make the schema public so it can be exercised by tests,
and add schema validation tests covering the new size limit.
AI-assisted-by: qwen3.7-plus
* ✨ Add media-processor service for image and font processing
Externalizes ImageMagick and FontForge subprocess invocations into a
separate Node.js HTTP service (media-processor/). Backend dispatches
via feature flag :use-remote-media-processing.
Key changes:
- media-processor module (TypeScript, Express 5, Sharp, FontForge/woff)
- POST /api/image/info, /api/image/thumbnail, /api/font/generate
- Resource limits: 128MP rejection, prlimit (512MB + 30s CPU)
- Streaming multipart via SequenceInputStream
- app.media split into validation (leaf), local (shell impls), remote (HTTP)
- Schema enforcement: :upload and :input schemas in validation namespace
- Configurable timeout (PENPOT_MEDIA_PROCESSING_SERVICE_TIMEOUT)
- 78 tests across 4 files (image, font, middleware, config)
- FontForge path escaping for command injection prevention
- Parallel font variant conversions with Promise.all
AI-assisted-by: mimo-v2.5-pro
* 🐳 Revert docker-compose changes from media-processor commit
Remove docker-compose.yaml modifications that were part of the media-processor
service commit. The media-processor service definition, flags, and environment
variables are reverted to their previous state.
AI-assisted-by: qwen3.7-plus
* ⬆️ Update dependencies
* 🐛 Fix PR review issues in media-processor
- Font path bug: sfntToWoff and woff2ToSfnt now copy input to temp dir
when input is a file path, ensuring output lands in expected location
- Error preservation: execCommand preserves killed/signal/code properties
from child process errors for OOM detection
- Content-Length: service-multipart-request calculates and includes
Content-Length header for streaming multipart requests
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor
- Rename PENPOT_MEDIA_PROCESSOR_SECRET_KEY to PENPOT_MEDIA_PROCESSOR_SHARED_KEY
in devenv to match backend config key
- Fix timeout middleware to destroy request AFTER response finishes,
preventing truncated 504 responses
- Fix quality=0 parsing to preserve explicit zero (was silently overridden to 85)
- Replace require('fs') with proper ES module import in upload-storage.ts
- Refactor font conversion temp-dir boilerplate into withTempInput helper
- Document FontForge escaping limitations (single quotes only)
- Fix misleading comment in image.ts about sharp metadata decoding
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor (round 2)
- Fix queue middleware to skip next() when response already ended,
preventing orphaned work after timeout
- Fix hybrid storage to use disk when Content-Length is absent (chunked
transfer), preventing unbounded memory allocation
- Add source image format validation in generateThumbnail to reject
unsupported formats (TIFF, BMP, etc.) with 400 instead of 500
- Remove dead code in convertFont for unreachable woff→woff path
- Remove unused isEnabled() method from LokiLogTransport
- Fix sfntToWoff to use correct extension (.ttf/.otf) based on source type
- Extract queue middleware to separate file for testability
- Add comprehensive tests for queue middleware and upload storage
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor (round 3)
- Fix disk-backed upload cleanup after successful requests by adding
cleanup middleware that removes temp files on response finish/close
- Wrap sharp metadata/decoding errors as 400 validation errors instead
of 500 internal errors
- Only apply flatten() for JPEG output to preserve alpha channel in
PNG and WebP outputs
AI-assisted-by: qwen3.7-plus
* ✨ Add comprehensive tests for media-processor
Phase 1 - Cleanup verification:
- Add cleanup middleware unit tests (6 tests)
- Add HTTP upload cleanup integration tests (5 tests)
Phase 2 - Error handling & alpha preservation:
- Add sharp error wrapping tests (4 tests)
- Add HTTP malformed image tests (2 tests)
- Add alpha preservation tests (3 tests)
Phase 3 - Edge cases:
- Add upload storage edge case tests (3 tests)
- Add queue middleware edge case tests (4 tests)
Phase 4 - Backend mock verification:
- Fix backend mocks to include :mtype field in image info responses
- Verify all error codes match actual service behavior
Total: 27 new tests added (160 tests passing)
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor (round 4)
- Add Zod validation constraints for config values (int, positive, min)
- Fix auth middleware to compare Buffer byte lengths instead of string lengths
- Validate requested output dimensions in generateThumbnail (crop mode)
- Change queue middleware to release slot via callback in finally block
- Add comprehensive tests for all fixes
AI-assisted-by: qwen3.7-plus
* 🐛 Close HTTP response streams in backend media remote
- Wrap stream consumption in try/finally with .close() calls
- Add tests to verify stream closure for info, font-convert, and thumbnail
AI-assisted-by: qwen3.7-plus
* 🐛 Fix queue slot leak on upload failures
Make releaseQueue idempotent and attach fallback listener to release
slot when response finishes. This covers Multer errors that bypass
the route handler's finally block, preventing permanent queue stall.
AI-assisted-by: qwen3.7-plus
* 🐛 Cancel processing on timeout
Create AbortController in timeout middleware and abort signal when
timeout fires. Pass signal to Sharp and FontForge to cancel ongoing
processing and release resources when request is cancelled.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix code review issues in media-processor (round 6)
- Error handler: check headersSent before writing response to prevent
ERR_HTTP_HEADERS_SENT when timeout already sent 504
- Timeout config: increase default requestTimeout from 60s to 180s to
match font processing timeout (120s) and backend request timeout
- Image processing: check abort signal before starting Sharp operations
to cancel processing when timeout fires
- Queue lifecycle: remove res.on('close', release) fallback to hold
queue slot until processing completes, preventing concurrency limit
violation when client disconnects
AI-assisted-by: qwen3.7-plus
* 🐛 Close HTTP response stream in download-image
Wrap response body in with-open to ensure stream is closed after
writing to temp file, preventing HTTP connection leaks on repeated
URL imports.
AI-assisted-by: qwen3.7-plus
* 🐛 Close HTTP response stream on validation errors in download-image
Move with-open to wrap the entire validation and processing block,
ensuring the response body stream is closed even when validation fails
(non-2xx status, missing size, invalid media type). This prevents
HTTP connection leaks on repeated failed downloads.
Add test to verify stream closure on validation errors.
AI-assisted-by: qwen3.7-plus
* 🐛 Pass abort signal to Sharp toBuffer for timeout cancellation
Wrap Sharp's toBuffer() with Promise.race to check abort signal during
processing. This ensures large thumbnails stop processing when the
request times out, preventing wasted CPU/memory and queue capacity.
Add test to verify abort during toBuffer operation.
AI-assisted-by: qwen3.7-plus
* 🐛 Hold queue slot until Sharp completes and handle client disconnect
- Remove Promise.race from generateThumbnail — Sharp processing now
completes fully before queue slot is released, preventing concurrency
limit violations under timeout conditions
- Remove res.on("finish", release) fallback from queue middleware —
error handler now explicitly calls releaseQueue in all error paths
- Add res.on("close") handler in timeout middleware to abort signal
when client disconnects, ensuring processing stops early
- Add tests for client disconnect handling and queue slot lifecycle
AI-assisted-by: qwen3.7-plus
* 🐛 Address round 9 review findings
- Document Sharp 0.35.3 cancellation limitation in image.ts
- Add integration test for timeout cleanup with large images
- Fix font tools (sfntToWoff, woffToSfnt, woff2ToSfnt) to throw
ProcessingError on resource limit kills instead of returning null
- Validate font signatures for same-format conversions to prevent
arbitrary files from being persisted as valid fonts
- Fix concurrent mkdtemp race in upload-storage by using shared
initialization promise
AI-assisted-by: qwen3.7-plus
* 🐛 Address round 10 review findings
- Add tmpdir assertion in font.ts to prevent path injection
- Preserve original error in queue middleware catch handler
- Change auth middleware response type from "internal" to "authorization"
- Add cleanup flag to prevent double cleanup in cleanup middleware
- Move quality clamping into parseQuality function for consistency
- Add integration tests for quality parameter clamping at route level
- Update existing tests to match new auth response type
AI-assisted-by: qwen3.7-plus
* 🐛 Address round 11 review findings
- Extract releaseSlot helper in error-handler to reduce duplication
- Remove redundant try/catch in font.ts withTempDir cleanup
- Improve font path validation error message for clarity
- Move path validation before try/catch to prevent swallowing
- Add debug logging for cleanup failures in cleanup middleware
- Inline TransportTargetSpec type alias in logger.ts
- Extract logging middleware to separate file for consistency
- Remove duplicate MIME validation in image thumbnail route
- Add test for font path validation (outside tmpdir rejection)
- Add tests for error handler queue release across all branches
AI-assisted-by: qwen3.7-plus
* 🐛 Remove Content-Length header from multipart requests
The JDK's HttpClient rejects Content-Length as a restricted header,
causing IllegalArgumentException when sending multipart requests to the
media-processor. Remove the explicit Content-Length header and let the
JDK use chunked transfer encoding. The media-processor will use disk
storage for all multipart requests (safe default behavior).
Remove unused size computations (file-size, header-bytes, footer-bytes,
total-size) that were only used for Content-Length.
Update test to verify Content-Length is not present in request headers.
AI-assisted-by: qwen3.7-plus
* 🐛 Fix pino ESM bundling for media-processor
Mark pino and its transports (pino-pretty, pino-loki) as external to
avoid bundling issues with worker thread modules that reference
__dirname (not available in ES modules).
AI-assisted-by: qwen3.7-plus
Close the profile props schema to reject undocumented keys and add a
denylist for system-managed props like :subscription that should not be
user-writable via RPC.
Changes:
- Add system-managed-props denylist (#{:subscription})
- Close schema:props with :closed true
- Add tests for subscription rejection and valid key acceptance
AI-assisted-by: qwen3.7-plus
* ♻️ Rename nitrate config to admin-console
Rename user-facing configuration from 'nitrate' to 'admin-console':
- Feature flags: :nitrate -> :admin-console, :nitrate-bulk-create-profiles -> :admin-console-bulk-create-profiles
- Config keys: :nitrate-shared-key -> :admin-console-shared-key, :nitrate-backend-uri -> :admin-console-uri
- Shared-keys map entry: :nitrate -> :admin-console (setup.clj + main.clj)
- Env vars: PENPOT_NITRATE_SHARED_KEY -> PENPOT_ADMIN_CONSOLE_SHARED_KEY, PENPOT_NITRATE_BACKEND_URI removed (consolidated into PENPOT_ADMIN_CONSOLE_URI)
- Docker/nginx: PENPOT_NITRATE_URI -> PENPOT_ADMIN_CONSOLE_URI
Code namespaces, file paths, CSS classes, and i18n keys stay as-is.
AI-assisted-by: mimo-v2.5-pro
* ♻️ Rename initialize-user-in-nitrate-organization to initialize-user-in-organization
Part of the nitrate -> admin-console rename series. The function and all 9 references across 6 files have been renamed.
* ♻️ Rename :nitrate-bulk-create-profiles-not-allowed to :bulk-create-profiles-not-allowed
* ♻️ Inline nitrate-permissions into app.common.types.organization
- Delete app.common.types.nitrate-permissions and its test
- Move permission rules (allowed?, can-send-invitations?, etc.) into organization.cljc
- Harmonize all consumers to use alias cto for app.common.types.organization
- Update test runner and create organization_test.cljc
MCP tokens now use a separate JWT issuer claim (`urn:penpot:mcp-token`) instead of `access-token`, preventing them from being validated as API access tokens.
Fixes#10960
AI-assisted-by: qwen3.7-plus
* 🐛 Fix several issues in RPC command handlers
- Reject circular library references in link-file-to-library
- Add explicit team permission check in search-files
- Constrain search-term max length to 250 chars
- Include :deleted-at in file ETag for COND caching
- Move storage I/O outside DB transaction in create-file-thumbnail
AI-assisted-by: deepseek-v4-pro
* 📎 Check perms before circular link checks
* 🐛 Handle circular library reference error
Catch :circular-library-reference error from backend when linking
files to libraries. Show user-friendly toast notification instead of
propagating unhandled error. Add English and Spanish translations.
AI-assisted-by: qwen3.7-plus
An organization owner keeps read-only access to the teams of their
organization even when they are not a member, so removing them from a
team was navigating them away from content they are still allowed to
see, and they could walk right back in through the URL.
Publish a :team-role-change to :viewer for them instead of a
:team-membership-change, which reuses the existing real-time role
transition on both the dashboard and the workspace. Any other member is
notified as before.
Signed-off-by: Juanfran <juanfran.ag@gmail.com>
Server changes:
- Switch list ordering from DESC to ASC (oldest first)
- Flip cursor direction to > for forward pagination
- Add 'until' param for server-side upper-bound filtering
CLI changes:
- Add --from/--to flags mapping to server's since/until
- Streaming output for --all and --format ndjson
- Add --format ndjson option (one JSON object per line)
- Add --normalize-hints flag to strip dynamic values
- Add --output flag to write list results to file
- Add 'stats' subcommand with aggregations (signature, host,
tenant, version, source, kind, hour) reading from API, file, stdin
- stats input supports JSON, JSON array, and NDJSON formats
Test changes:
- Fix pagination assertions for ASC ordering
AI-assisted-by: mimo-v2.5-pro
The bulk profile creation endpoint creates already active profiles that
skip email verification and onboarding, so it should not be reachable on
production deployments. Add the `nitrate-bulk-create-profiles` flag,
disabled by default, and reject the call when it is not enabled.
Signed-off-by: Juanfran <juanfran.ag@gmail.com>
Implement RPC methods for querying server error reports with pagination
and filtering. Add CLI tool (tools/error-reports.mjs) for convenient
access with table and JSON output formats. Extract profile-id from audit
events and logging context for better error categorization. Build
improved HREF using request path when available.
AI-assisted-by: qwen3.7-plus
* 📎 Add postgresql client tool wrapper for devenv
* ♻️ Replace uuid-ossp defaults with gen_random_uuid() and add missing :id on insert
- Switch all DEFAULT uuid_generate_v4() to gen_random_uuid()
(built-in PG 13+, no extension required)
- Add explicit :id (uuid/next) to 4 db/insert! calls that were
relying on the DB default (team-profile-rel, project-profile-rel,
team-project-profile-rel)
- Drop uuid-ossp extension (no longer needed)
- Add missing uuid require to projects.clj and srepl/binfile.clj
AI-assisted-by: deepseek-v4-flash
---------
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
get-teams-detail, get-org-invitations and delete-org-invitations lacked
::rpc/auth false, so wrap-nitrate-sso ran on them whenever params carried
an organization-id. For SSO-active orgs this rejected the org owner's
admin-console reads with a 401, since their Penpot session has no SSO
grant for the org. These are shared-key protected management calls made
on the org owner's behalf and never use profile-id, so they should not
require an end-user SSO session — matching their sibling endpoints.
* ♻️ Add mcp integration state management refactor (#10226)
* ♻️ Add mcp integration state management refactor
* 🐛 Fix access tokens do not appear
* ♻️ Refactor some names
* ♻️ Refactor token deletion
---------
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
* 🐛 Fix stale MCP token data after create/regenerate (#10280)
Fix the root cause in profile.cljs: remove the optimistic conj from
access-token-created and instead chain a fetch-access-tokens after the
create-access-token API call succeeds. This ensures all callers get a
fresh, server-consistent token list automatically.
Suggested-by: niwinz
Signed-off-by: kapilvus <kapil69265@gmail.com>
Co-authored-by: kapilvus <kapilvus@gmail.com>
* ✨ Remove non-recoverable mcp key warning from regenerated modal (#10298)
---------
Signed-off-by: kapilvus <kapil69265@gmail.com>
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
Co-authored-by: kapil971390 <kapil69265@gmail.com>
Co-authored-by: kapilvus <kapilvus@gmail.com>
* 🐳 Add ImageMagick policy.xml resource limits to backend Docker image
Add a restrictive policy.xml to the backend Docker image that caps
ImageMagick resource usage: 256MiB memory, 512MiB map, 128MP area,
30s time limit, 16KP max dimensions. Blocks PS/EPS/PDF/XPS coders
to prevent Ghostscript attack surface.
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* ✨ Add timeout support to shell/exec!
Add optional :timeout parameter (in seconds) that uses
Process.waitFor(long, TimeUnit). On timeout, the process is
destroyed forcibly and an :internal/:process-timeout exception
is raised. Stdout/stderr readers handle IOException from closed
streams when the process is killed.
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* ♻️ Rename ::wrk/netty-executor to ::wrk/executor with cached pool
Replace DefaultEventExecutorGroup (fixed Netty thread pool) with a
cached thread pool (px/cached-executor) for general async task
offloading. The cached pool creates threads on demand and reuses
idle ones, which is more appropriate for blocking I/O workloads
(shell commands, message bus, rate limiting, etc.).
Changes:
- Rename ::wrk/netty-executor to ::wrk/executor in worker/executor.clj
- Switch implementation from DefaultEventExecutorGroup to px/cached-executor
- Update all ig/ref wiring in main.clj (msgbus, tmp cleaner, climit, rlimit, rpc)
- Remove ::wrk/netty-executor from redis.clj (let lettuce create its own
eventExecutorGroup instead of sharing a Netty executor)
- Assert executor is present in shell/exec! to prevent silent nil usage
- Remove executor-threads config (no longer needed for cached pool)
The ::wrk/netty-io-executor (NioEventLoopGroup) remains unchanged as it
handles actual non-blocking network I/O for Redis and S3.
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* 🔥 Remove im4java dependency and replace with direct ImageMagick CLI calls
- Replace im4java Java library with direct 'magick' CLI calls via shell/exec!
- Add PENPOT_IMAGEMAGICK_* config env vars for resource limits (thread, memory, map, area, disk, time, width, height)
- Use configurable ImageMagick environment with sensible defaults matching policy.xml
- Remove -Dim4java.useV7=true JVM flag from startup scripts
- Remove org.im4java/im4java from deps.edn
- All ImageMagick commands now use shell/exec! with 60s timeout and resource limits
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* 💄 Rename imagemagick env functions and optimize config reads
- Rename imagemagick-defaults -> imagemagick-default-env
- Rename imagemagick-env -> get-imagemagick-env
- Optimize to avoid double cf/get calls per config key
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* ✨ Add tests for shell/exec! timeout and media processing
- Add shell_test.clj: tests for exec! timeout, env vars, stdin, stderr
- Add media_test.clj: tests for info, generic-thumbnail, profile-thumbnail
- Fix generic-process to prefer explicit format over input mtype
- Fix shell/exec! to use cached executor when system has no executor
- Fix reduce-kv accumulator in set-env (must return penv)
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* ♻️ Refactor media/process to take system as first argument
- Change (defmulti process :cmd) -> (defmulti process (fn [_system params] (:cmd params)))
- Change (run params) -> (run system params)
- All process methods now receive [system params]
- Update all callers: rpc/commands/media, profile, auth, fonts
- Revert shell/exec! to require system with executor (no fallback)
- Fix lint warnings and formatting
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* 🔥 Remove unused app.svgo namespace
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* 🔥 Remove Node.js from backend Docker image
- Delete unused svgo-cli.js script
- Remove Node.js installation from Dockerfile.backend
- Remove svgo-cli.js copy from backend build script
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* 🔥 Remove unused process-error multimethod
- Remove process-error multimethod and its default handler
- Simplify media/run to directly call process
- Fix alignment in main.clj
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
* 📚 Add ImageMagick resource limits configuration to technical guide
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
---------
Co-authored-by: mimo-v2.5-pro <mimo-v2.5-pro@penpot.app>
When an organization invitation token is verified by a logged-out recipient
(e.g. an unregistered invitee opening the emailed link), profile-id is nil.
The team-invitation branch still evaluated get-org-membership eagerly, calling
nitrate with that nil profile-id. That request fails and surfaces as a generic
error, masking the clean :invalid-token response and dropping the user on the
login screen instead of the dedicated "Invite invalid" page.
Only query membership when a logged-in profile is present, so a canceled or
otherwise invalid org invite reaches the :invalid-token path as intended.