Clarify that plan files stay local and that only implementation,
tests, memories, and documentation belong in the final commit.
AI-assisted-by: Space Bunny Free
* ♻️ Build organization invitation audit event in frontend
* ♻️ Align invitation token profile-id with created-by
The invitation token carried the minter in :profile-id while the
invitation row tracks the creator in :created-by. Both mean the
inviter, so re-sends or re-requested links made them disagree and
forced a second response key, :user-who-send-invitation.
Mint :profile-id from :created-by in both token creators, backfill
it from the row on accept (covers stale in-flight tokens), and drop
the duplicate response key. The frontend maps :profile-id to the
unchanged :user-who-send-invitation audit prop.
AI-assisted-by: Muse Spark 1.3 Free
* ♻️ Reuse token ids in invitation accept response
Backfill :member-id with the accepting profile and drop the
:user-id duplicate from the verify-token response, mirroring the
:profile-id/:user-who-send-invitation cleanup. The frontend maps
:member-id to the unchanged :user-id audit prop.
AI-assisted-by: Muse Spark 1.3 Free
---------
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Add the User-Agent values used by Mattermost, OpenGraph.xyz and the
Twitter Card Validator to the production and development
link-preview routing maps.
This lets those crawlers reach the dynamic Open Graph response instead
of receiving the generic Penpot preview.
AI-assisted-by: space-bunny-free
* 🎉 Render different stroke styles per side
* 🔧 Stabilise per-side stroke snapshots against clip id churn
SVG clip ids come from a counter that lives for the whole test
process, so a snapshot holding one depends on how many clips other
tests emitted first. The masked-group export tests that landed on
develop shifted that count and broke two per-side snapshots, whose
only diff was the id.
Route the remaining per-side snapshots through with_stable_clip_ids,
which renumbers ids in order of appearance. No snapshot in the suite
now carries a generated id.
AI-assisted-by: claude-opus-5
* 🐛 Restore rate-limit headers and add Retry-After
The account-lockout change replaced the header-forwarding 429 handler
with a body-only one, so existing RPC rate-limit responses lost their
x-rate-limit-remaining and x-rate-limit-reset headers. Account lockout
never sent Retry-After either.
Make handle-error :rate-limit preserve ::http/headers and add a
retry-after header when the exception carries a non-nil :ttl in
seconds, keeping the current JSON body. Add focused tests for both the
lockout and the RPC limiter paths.
Document activation, defaults, password/LDAP scope, Redis fail-open
behavior, and the lockout risk, and record the final HTTP contract in
the backend auth memory.
Refs #11397
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Add Retry-After to RPC 429 and expose headers in CORS
Address review follow-ups on the account-lockout 429 contract:
- The RPC limiter now sets retry-after in its 429 headers (seconds
until the longest rejecting limit resets), so it matches the
account-lockout response and the HTTP standard.
- CORS exposes retry-after, x-rate-limit-remaining, and
x-rate-limit-reset so browser clients can read them.
- Use backticks for Retry-After and account-locked in the docs for
consistency with nearby sections.
Refs #11397
AI-assisted-by: deepseek-v4.1-flash
The push-audit-events initiator test injects a request with
:app.http/auth-key-id via :app.http/request metadata. prepare-rpc-params
always replaced that metadata with a fresh DummyRequest, so the key id
was lost and the initiator fell back to "app".
Honor a caller-supplied request map, merging body params into its
:params, and keep the dummy request only for non-map IRequest stubs
(reify requests used by the audit tests, which cannot be assoc'd).
Add focused regression tests for the three cases and document the
helper contract in the backend testing memory.
AI-assisted-by: deepseek-v4.1-flash
Paint the mask subtree into a native <mask mask-type="alpha"> and wrap
content under mask="url(#…)", matching GPU DstIn (soft/group masks).
Drop silhouettes use the same alpha mask; empty-fill SVG paths inherit
group fills.
Closes#11378
GitHub does not report mutation-created issue-to-PR links through
closedByPullRequestsReferences(userLinkedOnly: true), so the
verification in `gh.py link-issue` failed even when
addCloseIssueReferences succeeded and the link existed.
Drop the re-query and trust the successful mutation: the command now
fails only when a link target is missing or the mutation does not
return the issue. Update the tests, the gh helper memory, the PR/issue
workflow memories, and the create-pr skill so they no longer promise
verification.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Preserve layers panel scroll position across tab switches
Fixes#7440. Switching between the Layers, Assets and Tokens tabs in
the workspace left sidebar unmounts the active panel component, causing
its scroll position to reset to the top on re-entry.
Add a module-level `scroll-positions` atom keyed by page-id. The
layers scroll handler now also saves the current scrollTop value into
the atom; a `mf/with-effect` on the page-id dep restores it whenever
the `layers-toolbox*` component mounts or the page changes.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
* 🐛 Preserve sidebar scroll positions across tab switches
Replace the Layers-only global atom with a scroll store held in a
use-var in left-sidebar*, shared by the Layers, Assets and Tokens
panels through a new sidebar.scroll helper. Positions are keyed per
panel and page (or token set) and restore waits for list content to
settle, so deep positions in lazily rendered lists survive tab
switches.
Closes#7440.
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Add e2e coverage for sidebar scroll preservation
Port the regression tests from closed PR #7544 for issue #7440,
adapted to the current ref-based implementation and fixtures:
async restore needs polled assertions, and setup uses the shared
tokens helpers. Also add data-scroll-container hooks to the Assets
and Tokens scroll containers so the specs can locate them.
AI-assisted-by: muse-spark-1.3-contributor
* 📎 Fix rebase issue
---------
Co-authored-by: Sumit Ridhal <sridhal@redhat.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Bump the OpenCode V2 binary in the devenv image from 2.0.12 to the
current npm latest tag (2.0.15) and refresh the per-architecture
SHA-256 checksums for both arm64 and amd64 tarballs.
AI-assisted-by: deepseek-v4.1-flash
Add a link-issue command that creates GitHub's Development reference
and verifies both sides. Keep Closes in descriptions for context, but
make the API link the source of truth, including for merged PRs.
Add tests for successful links, missing verification, output, and
failures. Update the PR workflow memories and create-pr skill to use
the command.
AI-assisted-by: space-bunny-free
Remove the legacy @opencode-ai/plugin import and V1 server export so
the local plugin loads under OpenCode V2 without project dependencies.
Add a focused smoke test for the V2 export and tool registration, and
update the related script memories.
AI-assisted-by: space-bunny-free
A compose port mapping delivers traffic to the container address,
never to loopback, so `listen 127.0.0.1:8082` made
`ports: <host>:8082` fail from the host. Both configs (image
template and devenv) now use `listen 8082`, which binds every
interface and matches the implicit bind of the public
`listen 8080 default_server`.
Rewrite both block comments to state the new bind and who decides
access from outside the host. The scrape URI stays on
127.0.0.1:8082: it still reaches the socket.
AI-assisted-by: mimo-v2.6-flash-free
* 🐳 Add upstream diagnostics to nginx access log
Enrich every access-log line with the internal journey of the request:
the status the backend answered (us), the time spent connecting to it
(uct), the time spent waiting for its answer (urt) and the internal
address that served the request (ua).
A plain 502 line used to say nothing about where the request died. With
this format, the tail of the line classifies the failure: connection
rejected, backend accepted and hung (uct + urt under 1s), or backend
stuck until read timeout. This was the missing witness in the Sep 20
incident, where nginx received connection resets with zero timeouts and
zero rejections.
Applied both to the production image template and the devenv config.
With proxy_pass on variables there is no upstream keepalive, so uct
measures one real TCP connection per request.
Parsing the new fields (us, uct, urt, ua) on the log shipper is left to
ops, so they can be filtered in Loki.
AI-assisted-by: glm-5.3-flash
* 🐳 Add stub_status endpoint for nginx metrics
Add a dedicated localhost-only server (listen 127.0.0.1:8082) exposing
/stub_status next to every other location of the public server. Ops can
run the official nginx-prometheus-exporter as a sidecar against
http://127.0.0.1:8082/stub_status and get nginx_connections_active,
accepted vs handled, reading/writing/waiting and request rates in
Prometheus.
Binding it to localhost and its own server keeps it unreachable from
outside the host and out of the public surface, and access_log off
avoids polluting Loki with one line per Prometheus scrape. The base
image already ships stub_status compiled in, so no image rebuild is
needed.
Applied both to the production image template and the devenv config.
AI-assisted-by: glm-5.3-flash
* ✨ Expose http server gate metrics (worker and connector)
The backend already measured dispatch latency but nothing reported the
state of the "house door": the xnio worker queue and threads, and the
monitor-level listener counters. This was the exact blind spot of the
Sep 20 incident, where the server kept answering health checks while it
accepted connections and dropped them without response.
Add a periodic metrics sampler that lives and dies with the http
server (single daemon thread, 15s interval, each sample guarded so an
unexpected error does not cancel subsequent runs) and publishes:
- worker (xnio MXBean gauges): penpot_http_worker_queue_size,
busy_threads, pool_size and max_pool_size. Negative samples are
discarded: the MXBean transiently reports -1 on the busy thread
count (verified live), and a stale negative would read as zero.
- listener (Undertow connector statistics, enabled via the new
:server/statistics yetti option): penpot_http_connector_active*
_connections gauge and requests_total / errors_total counters.
Undertow exposes absolute totals, so the sampler keeps a watermark
atom and publishes deltas, skipping (and moving forward past) a
counter reset.
The connector-level part depends on yetti v11.11, which now accepts
a :server/statistics server option (patch authored and released
upstream; before it, ListenerInfo#getConnectorStatistics always
returned nil).
New tests cover the samplers with fake MXBean/collector statistics
against real prometheus collectors, including the negative-sample
filter, the delta/watermark logic and the sampler lifecycle.
AI-assisted-by: glm-5.3-flash
* 🐛 Include jdk.management in the backend runtime JRE
The production image builds a trimmed JRE with jlink and omitted
jdk.management. Without that module the OS MXBean is
sun.management.BaseOperatingSystemImpl, which has no
getProcessCpuTime, getOpenFileDescriptorCount nor
getMaxFileDescriptorCount. The prometheus client StandardExports
reads those getters reflectively and collect() swallows the
NoSuchMethodException, so process_open_fds, process_max_fds and
process_cpu_seconds_total silently disappeared from /metrics while
the other process_* families kept flowing.
Verified against Prometheus: the app job only ever exposed
process_start_time_seconds, process_virtual_memory_bytes and
process_resident_memory_bytes; the fd and cpu families were absent.
Reproduced locally by running the backend metrics registry on a JRE
built with the same jlink module list (false/false/false) and on one
with jdk.management added (true/true/true).
Add the module to --add-modules and pin the metric contract with
backend-tests.metrics-test.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Build the http metrics sampler on promesa.exec
Replace the hand-rolled ScheduledThreadPoolExecutor and ThreadFactory
with promesa.exec primitives: px/scheduled-executor with a daemon
thread factory, and a px/schedule chain that reschedules the next
sample when the current one finishes.
Beyond fitting the existing periodic-task pattern (worker/cron,
rpc/rlimit), the chained schedule makes the docstring promise real:
with scheduleAtFixedRate an exception escaping the runnable cancelled
the following executions, while the reschedule now happens in a
finally block.
The sampler shutdown uses px/shutdown-now (shutdown! is deprecated in
promesa 12.0.0) to cancel the pending sample, keeping the previous
halt semantics.
The lifecycle test moves to the promesa predicates and a new test
covers the error-resilience promise: the first sample runs, throws,
and the next one is still scheduled.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Tighten the http metrics samplers
The samplers are leaf functions: they receive what they need and
publish it. Drop the internal nil guards (if there is no metrics
instance or no mxbean there is nothing to call them for) and move the
checks to the boundary, where the optional data is resolved:
sample-http-metrics now short-circuits with some-> and when-let.
Write the four worker gauges as four static operations instead of a
vector of pairs walked by doseq: the set is fixed, so the collection
only adds an allocation and hides each operation.
Drop the ! suffix from the sample-*-metrics family: ! marks a function
whose contract is to mutate state, while these report, and the mutation
happens in the mtx/run! they call. The constant true return, which only
existed so the removed guard tests could assert it, goes away too.
Tests follow the move: the internal-guard tests are replaced by one
boundary test (a nil server publishes nothing).
AI-assisted-by: deepseek-v4.1-flash
* 📚 Add the function design rules memory
Document the rules that came out of the http metrics sampler review:
preconditions are checked at the boundary instead of re-checked in the
core, optional-by-design data is guarded where the optionality is born,
a fixed set of operations is written statically, ! marks mutation and
not reporting, and production code is not shaped for tests.
Also state in the memory maintenance guide that memories must not use
manual line wrapping.
Linked from critical-info so it is read when designing a solution or
an API, not only when touching the samplers.
AI-assisted-by: deepseek-v4.1-flash
* 📚 Unwrap the critical-info memory lines
The memory maintenance guide forbids manual line wrapping, so rewrite
critical-info with one line per bullet and paragraph. A stray `*` at
the start of one continuation line is dropped.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Drop the redundant guard in the http server halt
create-metrics-sampler always returns the scheduler, so the sampler is
always present when integrant calls halt-key!; the nil check was dead
code, same as the yt/stop! call next to it.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add srepl helper to delete profiles by email
Add `delete-profiles-by-email!` to app.srepl.main. It accepts a
single email, a comma separated list of emails or a coll of emails,
resolves each profile, logs it to audit and enqueues the
delete-object task. The deleted-at is backdated with the configured
deletion-delay so profiles and their owned teams are purged on the
next gc pass.
Extract the per-email deletion logic into a private fn and reuse it
from `delete-profiles-in-bulk!`. Add tests for the new
`parse-emails` helper.
AI-assisted-by: glm-5.3-flash
* ✨ Add storage operation metrics for S3 and buckets
Expose Prometheus metrics for the object storage subsystem.
The S3 backend now attaches an AWS SDK MetricPublisher that counts
API calls, retries and latency per operation and target. The storage
layer counts logical operations and deduplication outcomes per Penpot
bucket, and the assets handlers count served requests per route.
Closes#11676
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Fix storage metrics labels, errors and test gaps
Address the review findings on the storage metrics commit.
Label reads with the object's own backend, count failed asset
serving as errors without swallowing them, and cover the failed
S3 call, S3 asset path and permission-denied branches with tests.
Also share the label helper and reuse the metrics test helper.
Closes#11676
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Harden storage metrics and fill test gaps
Address the second-round review findings on storage metrics.
Unknown backends now fail explicitly and count as errors, exists
stays paired with its dedup outcome, and the thumbnail, missing
storage, expired reads, unknown buckets and write failure paths
are covered by tests. Label coercion goes through the shared
metrics helper.
Closes#11676
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Harden storage metrics accuracy and coverage
Address the full-branch review findings on storage metrics.
Touch and delete emit only on changed rows, reads emit after the
backend fetch, unknown backends fail explicitly, and tempfile
mismatches count as unauthorized. Publisher nil policy, pairing
rules and attempt semantics are documented and covered by tests.
Closes#11676
AI-assisted-by: muse-spark-1.3-contributor
* ✨ Address full-branch review findings on storage metrics
Touch and delete resolve labels from the row, reads stay paired,
failures are covered by tests, and logging, ranges and docs are
tightened. Includes the label helper unit tests and the retries
wording clarification.
Closes#11676
AI-assisted-by: muse-spark-1.3-contributor
* ⚡ Label touch and del metrics from UPDATE RETURNING
The storage metrics change resolved metric labels for touch-object!
and del-object! with an extra SELECT per id-based call. Since app.main
instruments storage unconditionally, every GC collector and binfile
import paid that extra round trip: deleting a team with 10k media
objects doubled the storage_object statements exactly on the paths
that already process the most rows.
touch-object! and del-object! now take only the object id (UUID) and
read the labels from the updated row itself via RETURNING id, backend,
metadata: one statement, no pre-read, and labels that always match the
row actually mutated. del-object! additionally guards on deleted_at
IS NULL, so a repeated delete returns false and emits no metric.
Also from the review of the full branch: extract the duplicated
serve/emit/rethrow block in app.http.assets into one helper; give
penpot_storage_s3_timing explicit histogram buckets up to 60s (the
default cap at 7.5s hid the slow S3 calls the metric exists for);
drop the unused ::target-id config key from the S3 backend and
hardcode the :default target label until per-bucket routing lands.
AI-assisted-by: glm-5.3-flash
* ✨ Harden storage metric recording and definitions
The metric definition schema is now closed and declares every key
the collectors read: buckets, quantiles, max-age and reg. A typo
such as a misspelled ::mdef/buckets used to compile and silently
fall back to the default histogram buckets; it now fails at
startup.
The asset result-label fallback coerced an absent status to 500,
so a future serve path without a status would have counted
successes as errors. The mapping is now explicit and documented:
served below 400, unauthorized for 401/403, not-found for 404,
and error for everything else, including an absent status.
The never-fail try/catch around metric recording existed four
times with drift. One app.metrics/run-safe! helper replaces them:
it no-ops on a nil metrics instance and logs the first failure
per hint at warn level, then at debug, so a broken setup surfaces
once without flooding the log. The S3 publisher keeps its outer
try/catch: it is the SDK MetricPublisher contract boundary.
AI-assisted-by: glm-5.3-flash
* ✨ Make metrics mandatory and run! safe by default
Recording a metric must never change the behavior of the operation
being measured, so `run!` now catches recording failures itself: the
first failure per metric id logs at warn, later ones at debug. This
replaces the `run-safe!` helper, whose four copies had drifted, and
applies the guarantee to every emit site instead of only storage.
The metrics instance precondition is a plain assert, and the collector
lookup stays outside the recording guard, so a missing instance fails
hard even when asserts are disabled. Metrics is therefore no longer
optional: the storage, s3-backend and db-pool schemas require
`::mtx/metrics`, and the assets handler cfg always carries it.
`wrap-publisher` no longer returns nil for a nil instance, and the db
pool wires the prometheus tracker unconditionally.
AI-assisted-by: deepseek-v4.1-flash
* 🐛 Fix Japanese IME Enter duplication in comment input
Comment keydown handler treated every Enter as a Penpot
line-break action, so confirming an IME composition
duplicated the text with an extra newline and a
zero-width space. Guard the whole custom keydown
processing while the event belongs to an active IME
composition, mirroring the v3 text-editor precedent.
Closes#11757
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Keep composing Escape from closing comment thread
The parent floating-thread keydown handler closed the
thread on every Escape, including one that cancels an
active IME composition. Apply the same composition
guard so composing Escape stays owned by the IME while
plain Escape still closes the thread.
Closes#11757
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Test comment IME guard through key-action resolver
The composition predicate test only verified the
predicate itself, so a guard moved to the wrong place
or a handler bypassing it would stay green. Resolve
comment and thread keydowns through a pure
resolve-comment-key-action seam and verify the
observable behavior: composing keys yield :ime-owned
with zero Penpot side effects while the same plain
keys keep their existing commands.
Closes#11757
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
AI-assisted-by: muse-spark-1.3-contributor
* 🐛 Test comment IME handlers through direct calls
The key-action resolver only verified a return value,
so handler wiring regressions would stay green, and it
read the mention snapshot before handle-select ran,
changing the existing ordering. Remove the resolver,
extract the two handler bodies as directly callable
fns with the original select-first ordering, and
assert the fired side effects instead.
Closes#11757
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
AI-assisted-by: muse-spark-1.3-contributor
* 📚 Remove text-editor v3 references from comment IME docs
The comment IME guard is specific to the comment editor, so the
docstrings no longer present it as following a v3 text-editor or
render-engine precedent. Reviewers read that wording as tying this
comment bug fix to unrelated subsystems.
Only docstring text changes; handler logic and test assertions are
untouched.
Closes#11757
AI-assisted-by: deepseek-v4.1-flash
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
* 🐛 Review comments, and fix edge case
---------
Signed-off-by: Junsoo Choi <junsoo1172@gmail.com>
Signed-off-by: makesomethingshit <junsoo1172@gmail.com>
Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
* ⬆️ Upgrade MCP SDK to v2 and remove HTTP sessions
MCP's per-request protocol removes the need to retain HTTP sessions.
Use the v2 handler to manage each request's transport and lifecycle,
so requests can reach any server instance without session affinity
or the workaround that adopts sessions through private SDK fields.
Keep legacy SSE support and the shared plugin and Redis bridges.
Remove the shared expiry checker, including legacy SSE idle expiry;
SSE connections now remain until disconnection or server shutdown.
Verify stateless requests, token isolation, and legacy compatibility.
Resolves#11827
AI-assisted-by: gpt-6-astra
* 🔥 Remove legacy MCP SSE support
Use Streamable HTTP as the sole MCP client transport so the server no
longer needs a separate SSE connection registry or lifecycle.
Remove /sse and /messages, their nginx routes, and the server-legacy
dependency. Legacy SSE clients must switch to /mcp; older Streamable
HTTP clients remain supported. Document the migration and verify that
the removed endpoints return 404.
Resolves#11846
AI-assisted-by: gpt-6-astra
* 🐛 Fix error when reset overrides on a swapped copy
* 🐛 Add regression test for reset overrides inside group
Cover the case where a nested copy lives inside a group (not directly
under the instance root). After a swap, reset overrides must undo the
swap without error (#11656).
---------
Co-authored-by: Alejandro Alonso <alejandroalonsofernandez@gmail.com>
A compose port mapping delivers traffic to the container address,
never to loopback, so `listen 127.0.0.1:8082` made
`ports: <host>:8082` fail from the host. Both configs (image
template and devenv) now use `listen 8082`, which binds every
interface and matches the implicit bind of the public
`listen 8080 default_server`.
Rewrite both block comments to state the new bind and who decides
access from outside the host. The scrape URI stays on
127.0.0.1:8082: it still reaches the socket.
AI-assisted-by: mimo-v2.6-flash-free
* ✨ Send notification email on password change
Add a password-changed email template and send it after every
successful password change or first password set operation. This
gives users a visible signal when a new authentication factor is
established on their account.
Closes#11392
AI-assisted-by: longcat-2.0
* 🐛 Address code review feedback on password notification
- Send password-changed notification from recover-profile too
(forgot-password reset path was missing the email).
- Strengthen test assertions to verify email factory, recipient,
and name via :call-args-list instead of just call-count.
- Add negative test: no email sent when old-password is wrong.
- Wrap pre-existing update-profile-password test with send! mock
to keep its scope focused.
Ref: PR #11393
AI-assisted-by: longcat-2.0
Binfile import ran the whole import in a single transaction with
idle_in_transaction_session_timeout disabled (= 0), so a stalled
import could retain a connection pool slot indefinitely. Set a
finite 20 minutes ceiling via SET LOCAL instead (a compile-time
constant interpolated into the SQL; PostgreSQL does not accept bind
parameters on SET).
The v3 importer also located each file data by rescanning the full
zip entry collection once per manifest file and once per page,
making the cost close to quadratic on large files. Replace the
per-file regex matchers with a single classification pass that
groups entries by their raw path shape; consumers now lookup their
entries per file and page. As a deliberate tightening, the .json
suffix is matched literally: the previous regexes left the dot
unescaped, so crafted paths like files/<f>/tokensXjson or
objects/x-json matched by accident and are now ignored.
Closes#11579
AI-assisted-by: omen-alpha
* 🐳 Add upstream diagnostics to nginx access log
Enrich every access-log line with the internal journey of the request:
the status the backend answered (us), the time spent connecting to it
(uct), the time spent waiting for its answer (urt) and the internal
address that served the request (ua).
A plain 502 line used to say nothing about where the request died. With
this format, the tail of the line classifies the failure: connection
rejected, backend accepted and hung (uct + urt under 1s), or backend
stuck until read timeout. This was the missing witness in the Sep 20
incident, where nginx received connection resets with zero timeouts and
zero rejections.
Applied both to the production image template and the devenv config.
With proxy_pass on variables there is no upstream keepalive, so uct
measures one real TCP connection per request.
Parsing the new fields (us, uct, urt, ua) on the log shipper is left to
ops, so they can be filtered in Loki.
AI-assisted-by: glm-5.3-flash
* 🐳 Add stub_status endpoint for nginx metrics
Add a dedicated localhost-only server (listen 127.0.0.1:8082) exposing
/stub_status next to every other location of the public server. Ops can
run the official nginx-prometheus-exporter as a sidecar against
http://127.0.0.1:8082/stub_status and get nginx_connections_active,
accepted vs handled, reading/writing/waiting and request rates in
Prometheus.
Binding it to localhost and its own server keeps it unreachable from
outside the host and out of the public surface, and access_log off
avoids polluting Loki with one line per Prometheus scrape. The base
image already ships stub_status compiled in, so no image rebuild is
needed.
Applied both to the production image template and the devenv config.
AI-assisted-by: glm-5.3-flash
* ✨ Expose http server gate metrics (worker and connector)
The backend already measured dispatch latency but nothing reported the
state of the "house door": the xnio worker queue and threads, and the
monitor-level listener counters. This was the exact blind spot of the
Sep 20 incident, where the server kept answering health checks while it
accepted connections and dropped them without response.
Add a periodic metrics sampler that lives and dies with the http
server (single daemon thread, 15s interval, each sample guarded so an
unexpected error does not cancel subsequent runs) and publishes:
- worker (xnio MXBean gauges): penpot_http_worker_queue_size,
busy_threads, pool_size and max_pool_size. Negative samples are
discarded: the MXBean transiently reports -1 on the busy thread
count (verified live), and a stale negative would read as zero.
- listener (Undertow connector statistics, enabled via the new
:server/statistics yetti option): penpot_http_connector_active*
_connections gauge and requests_total / errors_total counters.
Undertow exposes absolute totals, so the sampler keeps a watermark
atom and publishes deltas, skipping (and moving forward past) a
counter reset.
The connector-level part depends on yetti v11.11, which now accepts
a :server/statistics server option (patch authored and released
upstream; before it, ListenerInfo#getConnectorStatistics always
returned nil).
New tests cover the samplers with fake MXBean/collector statistics
against real prometheus collectors, including the negative-sample
filter, the delta/watermark logic and the sampler lifecycle.
AI-assisted-by: glm-5.3-flash
* 🐛 Include jdk.management in the backend runtime JRE
The production image builds a trimmed JRE with jlink and omitted
jdk.management. Without that module the OS MXBean is
sun.management.BaseOperatingSystemImpl, which has no
getProcessCpuTime, getOpenFileDescriptorCount nor
getMaxFileDescriptorCount. The prometheus client StandardExports
reads those getters reflectively and collect() swallows the
NoSuchMethodException, so process_open_fds, process_max_fds and
process_cpu_seconds_total silently disappeared from /metrics while
the other process_* families kept flowing.
Verified against Prometheus: the app job only ever exposed
process_start_time_seconds, process_virtual_memory_bytes and
process_resident_memory_bytes; the fd and cpu families were absent.
Reproduced locally by running the backend metrics registry on a JRE
built with the same jlink module list (false/false/false) and on one
with jdk.management added (true/true/true).
Add the module to --add-modules and pin the metric contract with
backend-tests.metrics-test.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Build the http metrics sampler on promesa.exec
Replace the hand-rolled ScheduledThreadPoolExecutor and ThreadFactory
with promesa.exec primitives: px/scheduled-executor with a daemon
thread factory, and a px/schedule chain that reschedules the next
sample when the current one finishes.
Beyond fitting the existing periodic-task pattern (worker/cron,
rpc/rlimit), the chained schedule makes the docstring promise real:
with scheduleAtFixedRate an exception escaping the runnable cancelled
the following executions, while the reschedule now happens in a
finally block.
The sampler shutdown uses px/shutdown-now (shutdown! is deprecated in
promesa 12.0.0) to cancel the pending sample, keeping the previous
halt semantics.
The lifecycle test moves to the promesa predicates and a new test
covers the error-resilience promise: the first sample runs, throws,
and the next one is still scheduled.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Tighten the http metrics samplers
The samplers are leaf functions: they receive what they need and
publish it. Drop the internal nil guards (if there is no metrics
instance or no mxbean there is nothing to call them for) and move the
checks to the boundary, where the optional data is resolved:
sample-http-metrics now short-circuits with some-> and when-let.
Write the four worker gauges as four static operations instead of a
vector of pairs walked by doseq: the set is fixed, so the collection
only adds an allocation and hides each operation.
Drop the ! suffix from the sample-*-metrics family: ! marks a function
whose contract is to mutate state, while these report, and the mutation
happens in the mtx/run! they call. The constant true return, which only
existed so the removed guard tests could assert it, goes away too.
Tests follow the move: the internal-guard tests are replaced by one
boundary test (a nil server publishes nothing).
AI-assisted-by: deepseek-v4.1-flash
* 📚 Add the function design rules memory
Document the rules that came out of the http metrics sampler review:
preconditions are checked at the boundary instead of re-checked in the
core, optional-by-design data is guarded where the optionality is born,
a fixed set of operations is written statically, ! marks mutation and
not reporting, and production code is not shaped for tests.
Also state in the memory maintenance guide that memories must not use
manual line wrapping.
Linked from critical-info so it is read when designing a solution or
an API, not only when touching the samplers.
AI-assisted-by: deepseek-v4.1-flash
* 📚 Unwrap the critical-info memory lines
The memory maintenance guide forbids manual line wrapping, so rewrite
critical-info with one line per bullet and paragraph. A stray `*` at
the start of one continuation line is dropped.
AI-assisted-by: deepseek-v4.1-flash
* ♻️ Drop the redundant guard in the http server halt
create-metrics-sampler always returns the scheduler, so the sampler is
always present when integrant calls halt-key!; the nil check was dead
code, same as the yt/stop! call next to it.
AI-assisted-by: deepseek-v4.1-flash
* ✨ Add srepl helper to delete profiles by email
Add `delete-profiles-by-email!` to app.srepl.main. It accepts a
single email, a comma separated list of emails or a coll of emails,
resolves each profile, logs it to audit and enqueues the
delete-object task. The deleted-at is backdated with the configured
deletion-delay so profiles and their owned teams are purged on the
next gc pass.
Extract the per-email deletion logic into a private fn and reuse it
from `delete-profiles-in-bulk!`. Add tests for the new
`parse-emails` helper.
AI-assisted-by: glm-5.3-flash
A masked group now closes its focus scope on the mask pass, pops the
nested stacks on the content pass only, and draws its exit strokes on
the content pass. The focus depth also resets when each tile's walk
starts.
Closes#11805
AI-assisted-by: claude-opus-5
* ✨ Add account lockout after failed login attempts
Implement per-account brute-force protection using a Redis-backed
failed-login counter. After 5 failed attempts within 15 minutes, the
account is temporarily locked out and all login attempts (including
with the correct password) are rejected with a 429 response.
Closes#11397
AI-assisted-by: longcat-2.0
* 🐛 Bind LDAP session to directory-verified profile
The account-lockout change added a shortcut that preferred the
profile matching the typed email over the one returned by the LDAP
directory. These can differ with aliases, UPNs, or multi-valued mail
attributes, letting a user with valid LDAP credentials bind a session
to another Penpot account.
Keep the typed-email profile only for lockout checks. After LDAP
succeeds, resolve the session profile from the directory identity as
before and clear failed attempts on that profile.
AI-assisted-by: deepseek-v4.1-flash
Imported token sets were collapsed, hiding what had just been imported.
Fixes#9819
Signed-off-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Akshit Nassa <akshitnassa412@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
Classify save failures as transient or terminal (`transient-error?`
over the repo retryable types plus `:invalid-save-response`).
Transient failures keep the head commit queued under a new `:retrying`
status and resend it with backoff (2s/8s/20s, then terminal):
stamp rotation reuses the same `:commit-id`, the in-flight guard
prevents double-sends, and episode tokens silence stale timers.
One tagged reconnect notice per episode (hidden on save and on
terminal failure, silent recovery) plus a `:retrying` save-indicator
state; the browser `online` event and new edits resume the episode.
Terminal failures keep the exact `:error` path. Covers tasks 4, 6
and 7 with 31 persistence tests; updates the persistence memory.
Relates to #11724
AI-assisted-by: muse-spark-1.3-contributor
Connectivity and gateway failures (network, offline, 502/503 and
nitrate configuration) are not application defects, but offline fell
through to :default and 502/503 rendered exception-page, so they
reached the internal error reports and alerts with the full payload
(stack plus the last events). They are now classified as environment
failures and reported as audit-only handled-exception events.
generate-report accepts an explicit :format, as keyword arguments or as
a trailing map. :compact keeps the context header plus type, code and
uri, and skips the stack, the ex-data dump (which may contain request
headers) and the last-events list. flash derives the payload format from
the cause, so environment failures get a compact report; the audit event
name stays the canonical one requested by the caller
(handled-exception/unhandled-exception) because external tooling filters
on those names. Environment fingerprints drop the stack frame, so
grouping does not depend on the internal call site.
submit-report now requires an exception cause: a report without one is
ignored instead of using a separate fallback fingerprint, so a single
fingerprint format governs every report.
:offline gets its own handler and both connectivity handlers show the
new errors.connection-error message instead of the generic toast.
Closes#11743
AI-assisted-by: deepseek-v4.1-flash
Add a report governor in app.main.errors: each report carries
a fingerprint, the first occurrence is always emitted, and
repeats within 2 minutes are counted and included in the next
emitted report as :occurrences. The fingerprint cache is
bounded by evicting the oldest entry.
flash reserves the report before generating it, so suppressed
occurrences do not build a report. static.cljs now passes the
cause so the exception page gets a full fingerprint.
Closes#11726
AI-assisted-by: deepseek-v4.1-flash