diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md
index f285574bb8..1f18900d0f 100644
--- a/.agents/skills/create-pr/SKILL.md
+++ b/.agents/skills/create-pr/SKILL.md
@@ -73,7 +73,8 @@ one. Title or description fixes belong to Update mode.
Write the title and body following `mem:workflow/creating-prs` (title format,
description structure, writing principles) and `mem:workflow/creating-commits`
(commit type emojis). Derive the title and body from the commits and, when
-there is one, from the issue body. Reference the issue with `Closes #NNNN`.
+there is one, from the issue body. Include `Closes #NNNN` for readable context,
+but do not treat it as the link; use `python3 scripts/gh.py link-issue ` after creating the PR.
Repeat the `AI-assisted-by:` trailer in the body, once per model that worked
on the branch, so the PR states the assistance where a reviewer reads it. The
@@ -92,6 +93,13 @@ gh pr create --repo penpot/penpot --base "" --title "" \
repository default, which is wrong for a branch cut from `staging`. `--project
"Main"` is required by `mem:workflow/creating-prs`.
+If an issue is present, run the explicit assignment and verification command
+from `mem:workflow/creating-prs` before reporting success:
+
+```bash
+python3 scripts/gh.py link-issue
+```
+
### 5. Report
Report the PR URL and stop.
@@ -108,6 +116,14 @@ gh pr edit --repo penpot/penpot --title "" --body-file /tmp/pr-b
gh pr view --repo penpot/penpot --json title,body
```
+If the updated body contains `Closes #NNNN`, run the explicit assignment
+command from `mem:workflow/creating-prs` and require its verification to
+succeed:
+
+```bash
+python3 scripts/gh.py link-issue
+```
+
4. Report and stop.
## User context
diff --git a/.opencode/plugins/penpot.js b/.opencode/plugins/penpot.js
index c231907396..c2e17c9f6b 100644
--- a/.opencode/plugins/penpot.js
+++ b/.opencode/plugins/penpot.js
@@ -1,22 +1,9 @@
-// Penpot opencode plugin: custom tools for Penpot development.
+// Penpot OpenCode V2 plugin: custom tools for Penpot development.
//
-// Dual V1 + V2 implementation from a single file:
-// - OpenCode V1 (>= 1.18.29) calls the default export's `server()` and uses
-// the returned `tool` map (built with the `tool()` helper from
-// `@opencode-ai/plugin`).
-// - OpenCode V2 reads the default export's `id` and `setup()` and ignores
-// `server()`. Tools are registered via `ctx.tool.transform()` with JSON
-// Schema inputs, and `execute` returns `{ content }`.
-// See https://opencode.ai/v2/docs/build/plugins/migrate-v1
-//
-// NOTE: the V2 side intentionally does NOT
-// `import { Plugin } from "@opencode/plugin"`. At runtime `Plugin.define` is
-// the identity function, so a plain `{ id, setup }` object is equivalent, and
-// skipping the import keeps this plugin dependency-free
-// (`.opencode/package.json` is gitignored, so a new dependency declared there
-// would not travel with this file).
+// Tools are registered with `ctx.tool.transform()` and JSON Schema inputs.
+// Keep this plugin dependency-free so the auto-discovered local plugin loads
+// without project npm dependencies.
-import { tool } from "@opencode-ai/plugin"
import path from "path"
import { spawn } from "child_process"
@@ -106,64 +93,6 @@ function executeParenRepair({ files, code }, directory) {
})
}
-// --- V1 tool definitions (OpenCode V1 calls `server()` below) ---
-
-const penpotPsqlTool = tool({
- description:
- "Execute a SQL command against the Penpot database. Uses the defaults from scripts/psql.",
-
- args: {
- sql: tool.schema
- .string()
- .describe("SQL command to execute"),
-
- test: tool.schema
- .boolean()
- .describe("Use the penpot_test database")
- .optional(),
- },
-
- async execute(args, context) {
- return executePsql(args.sql, args.test === true, context.worktree)
- },
-})
-
-const parenRepairTool = tool({
- description:
- "Fix mismatched parentheses/braces in Clojure files (.clj, .cljs, .cljc) then reformat with cljfmt.",
-
- args: {
- // A string is used instead of an array so OpenCode displays it
- // in the generic tool invocation.
- files: tool.schema
- .string()
- .describe(
- "Comma-separated file paths to fix, for example: frontend/src/app/config.cljs, backend/src/core.clj",
- )
- .optional(),
-
- code: tool.schema
- .string()
- .describe("Code string to fix via stdin")
- .optional(),
- },
-
- async execute(args, context) {
- return executeParenRepair(args, context.worktree)
- },
-})
-
-async function server() {
- return {
- tool: {
- "paren-repair": parenRepairTool,
- "penpot-psql": penpotPsqlTool,
- },
- }
-}
-
-// --- V2 setup (OpenCode V2 calls `setup()` and ignores `server()`) ---
-
const penpotPsqlInputSchema = {
type: "object",
properties: {
@@ -199,10 +128,7 @@ const parenRepairInputSchema = {
}
async function setup(ctx) {
- // Plugin instance location. This is not the location of every session the
- // tools may run for, but it is the closest V2 equivalent of the V1
- // per-execution `context.worktree` (the repo checkout the plugin loaded
- // from), which is what both tools need as cwd / script base.
+ // Use the plugin instance location as the working directory for both tools.
const directory =
ctx.location.directory ?? ctx.location.project?.canonical
@@ -240,5 +166,4 @@ async function setup(ctx) {
export default {
id: "penpot",
setup,
- server,
}
diff --git a/.opencode/test/penpot.test.js b/.opencode/test/penpot.test.js
new file mode 100644
index 0000000000..c87d1c3540
--- /dev/null
+++ b/.opencode/test/penpot.test.js
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import plugin from "../plugins/penpot.js"
+
+test("exports only the OpenCode V2 plugin contract", () => {
+ assert.equal(plugin.id, "penpot")
+ assert.equal("server" in plugin, false)
+})
+
+test("registers the Penpot tools during setup", async () => {
+ const tools = []
+ const context = {
+ location: { directory: "/tmp/opencode/penpot-plugin-test" },
+ tool: {
+ async transform(apply) {
+ apply({
+ add(tool) {
+ tools.push(tool)
+ },
+ })
+ },
+ },
+ }
+
+ await plugin.setup(context)
+
+ assert.deepEqual(
+ tools.map((tool) => tool.name).sort(),
+ ["paren-repair", "penpot-psql"],
+ )
+})
diff --git a/.serena/memories/backend/audit-log.md b/.serena/memories/backend/audit-log.md
new file mode 100644
index 0000000000..fe401cef0c
--- /dev/null
+++ b/.serena/memories/backend/audit-log.md
@@ -0,0 +1,51 @@
+# Backend Audit Log
+
+Penpot records what users do as events in the Postgres `audit_log` table. There are two producers (the backend RPC layer and the frontend app) and four consumers (webhooks, error reporters, telemetry shipping, and the Nexus archive). Everything below follows that flow: purpose, storage, producers, consumers, archival.
+
+## Purpose
+
+- The audit log answers "who did what, when, from where": every RPC mutation and selected frontend actions become a row with `name`, `type`, `profile-id`, `ip-addr`, `props` and `context`. Product analytics, abuse investigation and compliance exports all read from here, so keep events truthful and never put secrets in `props`.
+- It is also the trigger bus for side effects: the same event object fans out to webhooks, error reporting and telemetry without the RPC handler knowing. New features should reuse this bus instead of building parallel notification paths.
+
+## Storage
+
+- Live `audit_log` columns: `id` uuid PK default `gen_random_uuid()`; `name`/`type` text NOT NULL; `created_at` timestamptz NOT NULL default `now()` (server time, the source of truth); `tracked_at` timestamptz default `now()` (client-claimed time, corrected on ingest); `profile_id` uuid NOT NULL; `source` text telling full rows (`backend`/`frontend`) apart from anonymized copies (`telemetry:backend`/`telemetry:frontend`); `ip_addr` inet; `props`/`context` jsonb holding transit-encoded maps; `archived_at` timestamptz set once Nexus acknowledges the row.
+- Indexes: PK on `(id)`; partial `created_at WHERE archived_at IS NULL` serving the archive scan; partial `archived_at WHERE archived_at IS NOT NULL` serving the GC; `(source, created_at)` serving the telemetry scan. Each consumer has its own index, so a slow consumer never blocks the others.
+
+## Backend producers (`app.loggers.audit`)
+
+- Most backend events need no manual code: `wrap-audit` in `app.rpc` runs after every RPC handler when `:webhooks`, `:audit-log` or `:telemetry` is on (unless the command sets `::audit/skip`) and builds the event via `prepare-rpc-event`. The event name defaults to the command name (prefixed with `-` outside `main`), props default to the request params, and timestamps come from the server request time.
+- Commands customize through result metadata (`rph/with-meta`): `::audit/replace-props` swaps the props wholesale (auth commands use `profile->props` so a register event carries the profile, not the password), `::audit/props` merges extras, `::audit/context`/`profile-id`/`name`/`type` override the defaults. `clean-props` always strips nils, qualified keys and `:session-id/:password/:old-password/:token/:client-secret` as a last line of defense.
+- `submit` is the normal entry point (fills defaults, validates `schema:event`, runs inside `tx-run!`, logs failures without failing the RPC). `insert` is the low-level one for CLI/helpers and the webhook subsystem: direct write, no webhook/telemetry fan-out, silent unless `:audit-log` is on. Boot emits `trigger/instance-start` from `setup/props` so every restart is visible in the log.
+
+## Consumers I: webhooks (`app.loggers.webhooks`)
+
+- Webhooks are the first dependent: when an event carries `::webhooks/event?`, `process-event` (worker task `:process-webhook-event`) finds the team's active webhooks from the event props (`team-id`, else `project-id`, else `file-id`), records a `trigger webhook` row, and enqueues one `:run-webhook` delivery per match. Batching and dedupe come from the audit event itself (`batch-key` + `batch-timeout`), not from webhook config.
+- `:run-webhook` POSTs the event in the webhook's `mtype` (JSON camelCase, transit, or form-encoded), logs each attempt in `webhook-delivery`, and disables the webhook after 3 consecutive errors. Delivery problems never touch the audit row itself.
+
+## Consumers II: error reporters (`app.loggers.database`, `app.loggers.mattermost`)
+
+- Both reporters listen for backend `:error` log records and for frontend crash events (recognized by the `::audit/event` marker), through a sliding-buffer channel so a flood of errors cannot stall the app. The database reporter persists them into `server-error-report` (source 4 = audit-log origin); the Mattermost reporter forwards a short notification to `:error-report-webhook` when configured.
+- Consequence for producers: crash reports only exist if the frontend collector is running and `push-audit-events` accepts `unhandled-exception`/`exception-page` events. Disabling the whole pipeline also blinds error reporting from the frontend.
+
+## Consumers III: telemetry (`app.loggers.audit` + `app.tasks.telemetry`)
+
+- Telemetry reuses the same table with anonymized shadow rows (`source LIKE 'telemetry:%'`): day-truncated timestamps, `0.0.0.0` IPs, props reduced to uuid/boolean/number values plus a few allowlisted fields (`lang`, `auth-backend`, derived `email-domain`, never raw emails), and a minimal context allowlist. Both full and shadow rows can coexist per event; that duplication is intentional.
+- The telemetry cron ships shadow rows to `:telemetry-uri` as JSON in 10k batches, deletes them on success, and purges leftovers older than 7d. Nothing is collected or sent on official hosts (`telemetry-excluded?` covers `penpot.app`/`penpot.dev`).
+
+## Frontend ingestion (`app.rpc.commands.audit`, `app.main.data.event`)
+
+- The browser cannot write to the table directly; it POSTs transit batches to `push-audit-events`, which stamps server `id`, session `profile-id`, request ip and server `created-at`, and distrusts the client clock (future or >1h-lagging `tracked-at` is reset, original preserved in context). The endpoint is a no-op without `:audit-log`/`:telemetry` or on a read-only pool.
+- The in-browser collector (`app.main.data.event`) only starts after `get-enabled-flags` confirms the backend wants events. It turns Potok events and explicit `ev/event` calls (nitrate membership changes, workspace file stats, crash reports) into a capped buffer (1024, chunks of 100, 2s debounce, current profile only) and sends fire-and-forget. `skip-audit?` exists for resumed dashboard actions so one user gesture is not counted twice.
+- Because collection is best-effort and includes `PerformanceObserver` noise (`performance-*` triggers), backend tests must never assert exact frontend event counts.
+
+## Archival to Nexus and retention
+
+- Long-term storage lives outside Penpot in Nexus. Every 5m the `:audit-log-archive` cron takes chunks of 128 unarchived rows (`FOR UPDATE SKIP LOCKED`), POSTs them as transit `{:events [...]}` authenticated with `x-shared-key: "nexus "` (`:nexus-shared-key`, else derived from the instance secret), and marks `archived_at=now()` only on HTTP 204, in the same transaction. Anything else is retried on the next run; a missing URI with the flag on raises `:task-not-configured`.
+- Every 5m the `:audit-log-gc` cron deletes all archived rows (no age filter), so archive must run before GC or data ships never. Cron dedup is best-effort (`mem:prod-infra/core`): two backends can fire the archiver twice, which is why the Nexus endpoint must be idempotent and the DB only marks acknowledged rows.
+- Flags live in `common/flags.cljc` varia and are enabled as `PENPOT_FLAGS=enable-`: `:audit-log`, `:audit-log-archive`, `:audit-log-gc`, `:audit-log-logger` (structured `app.audit` log). `:telemetry-enabled` config auto-adds `:enable-telemetry`.
+
+## Tests
+
+- `backend_tests/rpc-audit-test.clj` exercises the whole backend path (full-row insert, telemetry-only and dual-row modes, `submit*`, no-op without flags, `insert` gating, `prepare-rpc-event` resolution) with `with-redefs [cf/flags #{...}]` against real `audit_log` rows.
+- Other RPC suites mock `app.loggers.audit/submit` (nil return; `helpers.clj` stubs it globally) and assert on `:call-args-list`; any new command that must (or must not) emit an event needs the same treatment.
diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md
index da67831d4d..8f67530e71 100644
--- a/.serena/memories/backend/core.md
+++ b/.serena/memories/backend/core.md
@@ -8,6 +8,7 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m
- Storage abstraction, logical buckets, object lifecycle, deduplication, access, and garbage collection: `mem:backend/storage`.
- Embedded Ladybug graph experiment, projection, incremental sync, console, and risks: `mem:backend/graph-experiment`
- Auth flows, permission model, teams, projects, invitations, comments, webhooks, audit: `mem:backend/auth-permissions-product-domains`
+- Audit-log event collection (RPC wrapper, frontend ingestion), telemetry duality, webhook fan-out, error reporters, Nexus archival and retention: `mem:backend/audit-log`
- Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`.
## Stable namespace map
diff --git a/.serena/memories/scripts/gh.md b/.serena/memories/scripts/gh.md
index 58f73e997d..ef061edd60 100644
--- a/.serena/memories/scripts/gh.md
+++ b/.serena/memories/scripts/gh.md
@@ -9,6 +9,7 @@ repository via GraphQL and REST APIs through the authenticated `gh` CLI.
- Finding issues with no milestone.
- Fetching PR details by number or by milestone.
- Comparing milestone issues against CHANGES.md to find missing entries.
+- Explicitly linking a GitHub issue to a pull request and verifying both sides.
- Listing or inspecting GitHub Security Advisories (GHSA).
## Prerequisites
@@ -73,6 +74,17 @@ python3 scripts/gh.py prs --milestone "2.16.0" --state all
**Output**: JSON array to stdout; progress to stderr.
+### `link-issue`
+
+Explicitly assign a GitHub issue to a pull request and verify the relationship from both sides:
+
+```bash
+python3 scripts/gh.py link-issue
+# Short alias: python3 scripts/gh.py link
+```
+
+The command resolves both node IDs, calls `addCloseIssueReferences`, and checks the issue's manually linked PRs and the PR's closing issue references. It is safe to rerun, works for merged PRs, and does not close an issue retroactively. JSON goes to stdout; progress and errors go to stderr; a missing link exits non-zero.
+
### `advisories`
List or inspect GitHub Security Advisories for the repository.
diff --git a/.serena/memories/scripts/paren-repair.md b/.serena/memories/scripts/paren-repair.md
index 2774058fbc..a5c290ee60 100644
--- a/.serena/memories/scripts/paren-repair.md
+++ b/.serena/memories/scripts/paren-repair.md
@@ -27,11 +27,11 @@ bb scripts/paren-repair --help
```
`bb` must be invoked from the repo root so the path `scripts/paren-repair` resolves.
-## Native Tool Available (opencode)
+## Native Tool Available (OpenCode V2)
-A native opencode tool `paren-repair` is available (defined in
-`.opencode/plugins/penpot.js`, which registers it for both opencode V1 via
-`server()` and opencode V2 via `setup()`). The LLM can call it directly with:
+A native OpenCode V2 tool `paren-repair` is available. It is defined in
+`.opencode/plugins/penpot.js` and registered through `setup()`. The LLM can
+call it directly with:
- `files`: comma-separated file paths to fix (a string, not an array)
- `code`: Code string to fix via stdin
diff --git a/.serena/memories/scripts/psql.md b/.serena/memories/scripts/psql.md
index 68d7b66d0f..810905f7c6 100644
--- a/.serena/memories/scripts/psql.md
+++ b/.serena/memories/scripts/psql.md
@@ -25,12 +25,11 @@ scripts/psql --host myhost --user myuser --db mydb
`scripts/psql` must be invoked from the repo root so the path resolves.
-## Native Tool Available (opencode)
+## Native Tool Available (OpenCode V2)
-A native opencode tool `penpot-psql` is available (defined in
-`.opencode/plugins/penpot.js`, which registers it for both opencode V1 via
-`server()` and opencode V2 via `setup()`). The LLM can call it directly
-with:
+A native OpenCode V2 tool `penpot-psql` is available. It is defined in
+`.opencode/plugins/penpot.js` and registered through `setup()`. The LLM can
+call it directly with:
- `sql`: SQL command string to execute
- `test`: Boolean flag to use the `penpot_test` database
diff --git a/.serena/memories/workflow/creating-commits.md b/.serena/memories/workflow/creating-commits.md
index c3373ba29e..d632c9a471 100644
--- a/.serena/memories/workflow/creating-commits.md
+++ b/.serena/memories/workflow/creating-commits.md
@@ -50,4 +50,4 @@ that breaks them is wrong, even if the code is right.
## Referencing Issues
-Use `Closes #NNNN` (not `Fixes #NNNN`) to link a commit to a GitHub issue.
+Use `Closes #NNNN` (not `Fixes #NNNN`) in commit and PR body text for readable context. The explicit issue-to-PR link is assigned with `python3 scripts/gh.py link-issue ` as described in `mem:workflow/creating-prs`; do not rely on the keyword to create that link.
diff --git a/.serena/memories/workflow/creating-issues.md b/.serena/memories/workflow/creating-issues.md
index b1b4e9ade4..92f950a1b3 100644
--- a/.serena/memories/workflow/creating-issues.md
+++ b/.serena/memories/workflow/creating-issues.md
@@ -271,21 +271,13 @@ the issue was sourced.
### Link the PR to the issue
-Append `Closes #` to the PR body:
+Add `Closes #` to the PR body for readable context, then run the explicit assignment command from `mem:workflow/creating-prs`:
```bash
-gh pr view --repo penpot/penpot --json body --jq '.body' > /tmp/pr-body.md
-printf "\n\nCloses #\n" >> /tmp/pr-body.md
-gh pr edit --repo penpot/penpot --body-file /tmp/pr-body.md
-
-# Verify
-gh pr view --repo penpot/penpot --json body \
- --jq '.body | test("Closes #")'
+python3 scripts/gh.py link-issue
```
-**Note:** If the PR is already merged, `Closes` won't auto-close the issue —
-it only creates the "Development" sidebar link. This is the desired
-behavior since the issue is a tracking artifact.
+The command creates the GitHub Development link and verifies it from both the issue and PR. It is safe to rerun and does not close an issue retroactively when the PR is already merged. Do not rely on the body keyword as the assignment operation.
### Clean up
diff --git a/.serena/memories/workflow/creating-prs.md b/.serena/memories/workflow/creating-prs.md
index fa94a9afe0..5d5b132a6b 100644
--- a/.serena/memories/workflow/creating-prs.md
+++ b/.serena/memories/workflow/creating-prs.md
@@ -36,7 +36,7 @@ See `mem:workflow/creating-commits` for emoji codes. Squash merge uses the PR ti
Include concise sections covering:
- what changed and why;
-- related GitHub issues or Taiga stories (`Closes #NNNN`, `Relates to #NNNN`, `Taiga #NNNN`);
+- related GitHub issues or Taiga stories (`Closes #NNNN` for issues resolved by the PR, `Relates to #NNNN` for context, `Taiga #NNNN`); use the explicit issue assignment process below for each `Closes` issue;
- screenshots or recordings for UI-visible changes;
- testing performed and residual risk;
- breaking changes or migration notes, if any.
@@ -77,6 +77,12 @@ The "Note:" line is required at the top. Adjust if this is a manual (non-AI) PR.
- ❌ Migration notes unless breaking changes
- ❌ Regression fixes introduced during the PR (they're part of the development process, not the feature)
+## Explicit Issue Assignment
+
+- For each GitHub issue that a PR resolves, run `python3 scripts/gh.py link-issue ` after creating or editing the PR. Do not rely on `Closes #NNNN` in the body; it is only human-readable context.
+- The command calls `addCloseIssueReferences`, verifies the relationship from both the issue and PR, and exits non-zero if either side is missing. It is safe to rerun and also works for an already merged PR; it does not close an issue retroactively.
+- Skip this process for `Relates to #NNNN` and Taiga references, which do not represent a closing relationship.
+
## Before Opening
- Follow `mem:workflow/creating-commits` for commits
diff --git a/CHANGES.md b/CHANGES.md
index cdb6715b7d..327d5c8dcb 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -47,7 +47,7 @@
- Export multiple fills to SVG [#11466](https://github.com/penpot/penpot/issues/11466) (PR: [#11467](https://github.com/penpot/penpot/pull/11467))
- Add Penpot-specific board size presets (file thumbnail, template cover, plugin icon/cover) [#11561](https://github.com/penpot/penpot/issues/11561) (PR: [#11565](https://github.com/penpot/penpot/pull/11565))
-## 2.18.0 (Unreleased)
+## 2.18.0
### :rocket: Epics and highlights
@@ -177,9 +177,11 @@
- Fix workspace crash with index out of bounds when removing multiple interactions rapidly [#11546](https://github.com/penpot/penpot/issues/11546) (PR: [#11621](https://github.com/penpot/penpot/pull/11621))
- Fix workspace crash with 'too much recursion' error when editing grid layout [#11562](https://github.com/penpot/penpot/issues/11562) (PR: [#11563](https://github.com/penpot/penpot/pull/11563))
- Fix plugin API error when removing interactions [#11618](https://github.com/penpot/penpot/issues/11618) (PR: [#11621](https://github.com/penpot/penpot/pull/11621))
+- Fix MCP REPL server starting in multi-user mode and inheriting the main server bind address [#11631](https://github.com/penpot/penpot/issues/11631) (PR: [#11632](https://github.com/penpot/penpot/pull/11632))
- Fix chunked upload storing duplicate objects when the same chunk index is sent twice [#11634](https://github.com/penpot/penpot/issues/11634) (PR: [#11635](https://github.com/penpot/penpot/pull/11635))
- Fix access token expiration select always showing Never after picking another option (by @chulgil) [#11663](https://github.com/penpot/penpot/issues/11663) (PR: [#11664](https://github.com/penpot/penpot/pull/11664))
- Fix workspace dropping unsaved changes when read-only blocks persistence [#11672](https://github.com/penpot/penpot/issues/11672) (PR: [#11699](https://github.com/penpot/penpot/pull/11699))
+- Fix Plugin API failing silently when writing to an interaction the shape no longer has [#11780](https://github.com/penpot/penpot/issues/11780) (PR: [#11781](https://github.com/penpot/penpot/pull/11781))
### :sparkles: New features & Enhancements
@@ -203,6 +205,8 @@
- Adopt React Aria [#10802](https://github.com/penpot/penpot/issues/10802) (PR: [#10675](https://github.com/penpot/penpot/pull/10675))
- Add plugin API function for awaiting component updates beyond waitForLayoutUpdate [#10927](https://github.com/penpot/penpot/issues/10927) (PR: [#10964](https://github.com/penpot/penpot/pull/10964))
- Emit open-workspace-file audit event with file statistics on workspace load [#11106](https://github.com/penpot/penpot/issues/11106) (PR: [#11138](https://github.com/penpot/penpot/pull/11138))
+- Restrict optional ids to v4, v7 and v8 UUIDs in creation RPC endpoints [#11778](https://github.com/penpot/penpot/issues/11778) (PR: [#11777](https://github.com/penpot/penpot/pull/11777))
+
## 2.17.2
### :bug: Bugs fixed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b4e2618f4e..7a11d90851 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,12 +11,13 @@ Center](https://help.penpot.app/).
## Table of Contents
- [Prerequisites](#prerequisites)
+- [AI-Assisted Contributions](#ai-assisted-contributions)
- [Reporting Bugs](#reporting-bugs)
- [Pull Requests](#pull-requests)
- [Workflow](#workflow)
- [Branch naming](#branch-naming)
- [Format](#format)
- - [Title format](#title-format)
+ - [Title format](#title)
- [Description](#description)
- [Review process](#review-process)
- [What we won't accept](#what-we-wont-accept)
@@ -55,6 +56,12 @@ Center](https://help.penpot.app/).
without switching the fallback off. Every one of these paths is
gitignored.
+## AI-Assisted Contributions
+
+We support the responsible use of AI tools in the development process. However, all contributions to Penpot - including issues, pull requests, and any other submissions - must meet a reasonable standard of quality, accuracy, and human oversight.
+
+If AI-assisted content is used, it must be carefully reviewed and verified by a human before submission. Contributions that don't meet these standards may be rejected or closed without detailed review or a reply.
+
## Reporting Bugs
Report bugs via [GitHub Issues](https://github.com/penpot/penpot/issues).
@@ -100,7 +107,7 @@ Branch names are not enforced, but we recommend the following:
and no slashes (e.g. `fix-ellipse-icon-typo`, `feat-auto-link-libraries`).
Since PRs are squash-merged, the branch name does not survive into the
-commit history — what matters is the [PR title](#title-format).
+commit history — what matters is the [PR title](#title).
### Format
@@ -145,7 +152,7 @@ for more concrete information.
force-push during review, as it breaks comment threads.
- PRs require at least **one approval** before merge.
- We use **squash-merge** by default. The PR title becomes the final
- commit message, so follow the [title format](#title-format) above.
+ commit message, so follow the [title format](#title) above.
### What we won't accept
diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj
index 86778ab55f..152e0d7cb4 100644
--- a/backend/src/app/loggers/audit.clj
+++ b/backend/src/app/loggers/audit.clj
@@ -204,7 +204,7 @@
token-id (::actoken/id request)
token-type (::actoken/type request)]
{:external-session-id session-id
- :initiator (or key-id "app")
+ :initiator (or (d/name key-id) "app")
:access-token-id (some-> token-id str)
:access-token-type (some-> token-type str)
:client-event-origin client-event-origin
diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj
index 9a6e4f198d..8336e2dba3 100644
--- a/backend/test/backend_tests/rpc_audit_test.clj
+++ b/backend/test/backend_tests/rpc_audit_test.clj
@@ -35,6 +35,21 @@
"x-forwarded-for" "127.0.0.44"
"x-real-ip" "127.0.0.43"))))
+(t/deftest prepare-context-initiator-is-plain-string
+ ;; The initiator must always be a plain string, never a keyword: shared-key
+ ;; authenticated callers (exporter, admin-console) arrive as keywords on
+ ;; :app.http/auth-key-id and transit would persist them as "~:exporter".
+ (let [base {:headers {"x-forwarded-for" "127.0.0.44"}}]
+ (t/is (= "app" (:initiator (audit/prepare-context-from-request base))))
+ (t/is (= "exporter"
+ (:initiator (audit/prepare-context-from-request
+ (assoc base :app.http/auth-key-id :exporter)))))
+ (t/is (= "admin-console"
+ (:initiator (audit/prepare-context-from-request
+ (assoc base :app.http/auth-key-id :admin-console)))))
+ (t/is (string? (:initiator (audit/prepare-context-from-request
+ (assoc base :app.http/auth-key-id :nexus)))))))
+
(t/deftest push-events-1
(with-redefs [app.config/flags #{:audit-log}]
(let [prof (th/create-profile* 1 {:is-active true})
diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj
index 08f88438cb..7d7b1ad114 100644
--- a/backend/test/backend_tests/rpc_management_nitrate_test.clj
+++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj
@@ -1943,3 +1943,26 @@
(t/is (= "bar" (get-in event [:context :foo])))
(t/is (= (:full cf/version) (get-in event [:context :version])))
(t/is (= "app" (get-in event [:context :initiator]))))))))
+
+(t/deftest push-audit-events-initiator-is-plain-string
+ ;; Shared-key callers (e.g. admin-console) carry :app.http/auth-key-id as a
+ ;; keyword; the stored initiator must be a plain string, and a
+ ;; caller-supplied initiator must never survive (server context wins).
+ (with-mocks [audit-mock {:target 'app.loggers.audit/submit :return nil}]
+ (binding [cf/flags #{:audit-log}]
+ (let [prof (th/create-profile* 1 {:is-active true})
+ params {::th/type :push-audit-events
+ :events [{:name "context-test"
+ :profile-id (:id prof)
+ :type "action"
+ :context {:custom-key "custom-val"
+ :initiator "spoofed"}}]}
+ params (with-meta params
+ {::http/request (assoc http-request
+ ::http/auth-key-id :admin-console)})
+ out (th/management-command! params)]
+ (t/is (nil? (:error out)))
+ (let [[_ event] (:call-args @audit-mock)]
+ (t/is (= "custom-val" (get-in event [:context :custom-key])))
+ (t/is (= "admin-console" (get-in event [:context :initiator])))
+ (t/is (string? (get-in event [:context :initiator]))))))))
diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc
index b8b1274478..fc3c4d00de 100644
--- a/common/src/app/common/logic/libraries.cljc
+++ b/common/src/app/common/logic/libraries.cljc
@@ -2783,7 +2783,7 @@
(let [shape-inst (ctn/get-shape container id)
objects (:objects container)
parent (get objects (:parent-id shape-inst))
- head (ctn/get-component-shape container parent)
+ head (ctn/get-component-shape objects parent)
changes
(-> changes
(pcb/with-container container)
@@ -2802,6 +2802,10 @@
(pcb/with-objects objects)
(generate-sync-shape-direct file-full libraries container id true))]
+ ;; If the shape has been swapped, the reset also have to undo the swap. As the
+ ;; shape has been reset and it's not touched now, by the sync above, if we
+ ;; do another reset of the parent, the shape will be reset as it is in the
+ ;; near main component.
(cond-> changes
(some? swap-slot)
(generate-sync-head file-full libraries container id true))))
diff --git a/common/test/common_tests/logic/swap_and_reset_test.cljc b/common/test/common_tests/logic/swap_and_reset_test.cljc
index 6e1fa2e3da..c9434a9090 100644
--- a/common/test/common_tests/logic/swap_and_reset_test.cljc
+++ b/common/test/common_tests/logic/swap_and_reset_test.cljc
@@ -82,3 +82,63 @@
(validate #(t/is (= (copy-type %) :rect)))
(validate #(t/is (= (nested-component-id %) (rectangle-component-id %))))
(validate #(t/is (nil? (nested-swap-slot %)))))))
+
+;; The nested component sits inside a group, not directly under the instance root.
+;; Resetting overrides after a swap must undo the swap without error.
+(t/deftest test-swap-and-reset-override-inside-group
+ (letfn [(nested-in-copy [file]
+ (->> (ths/get-shape file :copy-group)
+ :shapes
+ first
+ (ths/get-shape-by-id file)))]
+
+ (let [;; ==== Setup
+ file
+ (-> (thf/sample-file :file1)
+
+ (tho/add-frame :frame-rectangle)
+ (ths/add-sample-shape :rectangle-shape :parent-label :frame-rectangle :type :rect)
+ (thc/make-component :rectangle :frame-rectangle)
+
+ (tho/add-frame :frame-circle)
+ (ths/add-sample-shape :circle :parent-label :frame-circle :type :circle)
+ (thc/make-component :circle :frame-circle)
+
+ (tho/add-frame :frame-main)
+ (tho/add-group :group-main :parent-label :frame-main)
+ (thc/instantiate-component :rectangle :nested-rectangle
+ :parent-label :group-main
+ :children-labels [:nested-rectangle-shape])
+ (thc/make-component :main :frame-main)
+
+ (thc/instantiate-component :main :copy
+ :children-labels [:copy-group
+ :copy-nested-rectangle
+ :copy-nested-rectangle-shape]))
+
+ rectangle-id (:id (thc/get-component file :rectangle))
+ circle-id (:id (thc/get-component file :circle))
+ main-nested-id (:id (ths/get-shape file :nested-rectangle))
+
+ ;; ==== Action – swap nested copy inside the group, then reset overrides
+ file-swapped
+ (tho/swap-component-in-shape file :copy-nested-rectangle :circle
+ :new-shape-label :copy-nested-swapped)
+
+ swapped (ths/get-shape file-swapped :copy-nested-swapped)
+
+ file'
+ (tho/reset-overrides file-swapped swapped)
+
+ restored (nested-in-copy file')]
+
+ ;; ==== Check – after swap
+ (t/is (= :circle (:type (tho/bottom-shape file-swapped :copy-nested-swapped))))
+ (t/is (= circle-id (:component-id swapped)))
+ (t/is (= main-nested-id (ctk/get-swap-slot swapped)))
+
+ ;; ==== Check – after reset: back to rectangle, no swap slot, file still valid
+ (t/is (some? restored))
+ (t/is (= :rect (:type (tho/bottom-shape-by-id file' (:id restored)))))
+ (t/is (= rectangle-id (:component-id restored)))
+ (t/is (nil? (ctk/get-swap-slot restored))))))
diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml
index 304d572194..e667bc3049 100644
--- a/docker/images/docker-compose.yaml
+++ b/docker/images/docker-compose.yaml
@@ -24,7 +24,7 @@
# WARNING: if you're exposing Penpot to the internet, you should remove the flags
# 'disable-secure-session-cookies' and 'disable-email-verification'
x-flags: &penpot-flags
- PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp
+ PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console
x-uri: &penpot-public-uri
PENPOT_PUBLIC_URI: http://localhost:9001
@@ -45,6 +45,13 @@ x-body-size: &penpot-http-body-size
x-secret-key: &penpot-secret-key
PENPOT_SECRET_KEY: change-this-insecure-key
+## Single database configuration block, shared by backend and admin-console.
+## Don't touch them unless you are using custom postgresql connection parameters.
+x-database: &penpot-database
+ PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
+ PENPOT_DATABASE_USERNAME: penpot
+ PENPOT_DATABASE_PASSWORD: penpot
+
networks:
penpot:
@@ -79,7 +86,7 @@ services:
# - "443:443"
penpot-frontend:
- image: "penpotapp/frontend:${PENPOT_VERSION:-2.17}"
+ image: "penpotapp/frontend:${PENPOT_VERSION:-2.18}"
restart: always
ports:
- 9001:8080
@@ -89,8 +96,9 @@ services:
depends_on:
- penpot-backend
- - penpot-exporter
+ - penpot-admin-console
- penpot-mcp
+ - penpot-exporter
networks:
- penpot
@@ -108,11 +116,16 @@ services:
environment:
<< : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri]
+
+ # Don't touch it; this uses an internal docker network to
+ # communicate with the admin-console.
+ PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000
+
# Set to "true" on hosts where IPv6 is disabled at kernel boot level.
# PENPOT_DISABLE_IPV6_LISTEN: "true"
penpot-backend:
- image: "penpotapp/backend:${PENPOT_VERSION:-2.17}"
+ image: "penpotapp/backend:${PENPOT_VERSION:-2.18}"
restart: always
volumes:
@@ -129,29 +142,24 @@ services:
## Configuration environment variables for the backend container.
+
environment:
- << : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key]
+ << : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key, *penpot-database]
- ## Database connection parameters. Don't touch them unless you are using custom
- ## postgresql connection parameters.
-
- PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
- PENPOT_DATABASE_USERNAME: penpot
- PENPOT_DATABASE_PASSWORD: penpot
+ # Don't touch it; this uses an internal docker network to
+ # communicate with the admin-console.
+ PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000
## Valkey (or previously redis) is used for the websockets notifications. Don't touch
## unless the valkey container has different parameters or different name.
-
PENPOT_REDIS_URI: redis://penpot-valkey/0
## Default configuration for assets storage: using filesystem based with all files
## stored in a docker volume.
-
PENPOT_OBJECTS_STORAGE_BACKEND: fs
PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
## Also can be configured to to use a S3 compatible storage.
-
# AWS_ACCESS_KEY_ID:
# AWS_SECRET_ACCESS_KEY:
# PENPOT_OBJECTS_STORAGE_BACKEND: s3
@@ -162,7 +170,6 @@ services:
## instance. Telemetry data will enable us to learn how the application is used,
## based on real scenarios. If you want to help us, please leave it enabled. You can
## audit what data we send with the code available on github.
-
PENPOT_TELEMETRY_ENABLED: "true"
PENPOT_TELEMETRY_REFERER: compose
@@ -170,7 +177,6 @@ services:
## service, but for production usage it is recommended to setup a real SMTP
## provider. Emails are used to confirm user registrations & invitations. Look below
## how the Mailpit service is configured.
-
PENPOT_SMTP_DEFAULT_FROM: no-reply@example.com
PENPOT_SMTP_DEFAULT_REPLY_TO: no-reply@example.com
PENPOT_SMTP_HOST: penpot-mailpit
@@ -180,14 +186,32 @@ services:
PENPOT_SMTP_TLS: "false"
PENPOT_SMTP_SSL: "false"
+ penpot-admin-console:
+ image: "penpotapp/admin-console:${PENPOT_VERSION:-2.18}"
+ restart: always
+
+ depends_on:
+ penpot-postgres:
+ condition: service_healthy
+
+ networks:
+ - penpot
+
+ environment:
+ << : [*penpot-public-uri, *penpot-secret-key, *penpot-database]
+
+ # Don't touch it; this uses an internal docker network to
+ # communicate with the frontend.
+ PENPOT_INTERNAL_URI: http://penpot-frontend:8080
+
penpot-mcp:
- image: "penpotapp/mcp:${PENPOT_VERSION:-2.17}"
+ image: "penpotapp/mcp:${PENPOT_VERSION:-2.18}"
restart: always
networks:
- penpot
penpot-exporter:
- image: "penpotapp/exporter:${PENPOT_VERSION:-2.17}"
+ image: "penpotapp/exporter:${PENPOT_VERSION:-2.18}"
restart: always
depends_on:
@@ -199,6 +223,7 @@ services:
environment:
<< : [*penpot-flags, *penpot-secret-key, *penpot-public-uri]
+
# Don't touch it; this uses an internal docker network to
# communicate with the frontend.
PENPOT_INTERNAL_URI: http://penpot-frontend:8080
diff --git a/docs/technical-guide/getting-started/docker.md b/docs/technical-guide/getting-started/docker.md
index a8c7b55ffa..66d7a311ba 100644
--- a/docs/technical-guide/getting-started/docker.md
+++ b/docs/technical-guide/getting-started/docker.md
@@ -1,5 +1,5 @@
---
-title: 1.3 Install with Docker
+title: 1.2 Install with Docker
desc: This Penpot technical guide covers self-hosting, Docker installation, configuration, updates, backups, and proxy setup with NGINX and Caddy. Try Penpot!
---
@@ -115,7 +115,90 @@ This will fetch the latest images. When you do docke
It is strongly recommended to update the Penpot version in small increments, rather than updating between two distant versions.
-**Important: Upgrade from version 1.x to 2.0**
+#### Upgrade to 2.18
+
+This version deploys a new service, **`penpot-admin-console`**, in the official `docker-compose.yaml`
+example. If you maintain your own compose file, you need to replicate the following changes manually:
+
+**1. Add the `enable-admin-console` flag**
+
+**NOTE:** Enabling the Admin Console is not required in this version, but it will be in a future release.
+We recommend setting it up now to avoid a more complex upgrade later.
+
+This step (the `enable-admin-console` flag) only applies to this version 2.18. In a future release, the
+Admin Console will be enabled by default and this flag will be removed. If you're upgrading directly to a
+version where the flag has already been removed, skip step 1 and go straight to steps 2–4, which remain
+required.
+
+Wherever you set `PENPOT_FLAGS` (frontend and backend):
+
+```diff
+- PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp
++ PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console
+```
+
+**2. Add the new `penpot-admin-console` service**
+
+```yaml
+ penpot-admin-console:
+ image: "penpotapp/admin-console:2.18"
+ restart: always
+
+ depends_on:
+ penpot-postgres:
+ condition: service_healthy
+
+ networks:
+ - penpot
+
+ environment:
+ PENPOT_PUBLIC_URI: http://localhost:9001
+ PENPOT_SECRET_KEY: change-this-insecure-key
+ PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
+ PENPOT_DATABASE_USERNAME: penpot
+ PENPOT_DATABASE_PASSWORD: penpot
+
+ # Don't touch it; this uses an internal docker network to
+ # communicate with the frontend.
+ PENPOT_INTERNAL_URI: http://penpot-frontend:8080
+```
+> Use the same `PENPOT_PUBLIC_URI`, `PENPOT_SECRET_KEY`, and database credentials you already have configured for `penpot-backend`.
+
+**3. Update `penpot-frontend`**
+
+- Add `penpot-admin-console` to `depends_on`.
+- Add the following environment variable:
+
+```diff
+ environment:
+ PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console
+ PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 367001600
+ PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600
+ PENPOT_PUBLIC_URI: http://localhost:9001
++
++ # Don't touch it; this uses an internal docker network to
++ # communicate with the admin-console.
++ PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000
+```
+
+**4. Update `penpot-backend`**
+
+Add the same variable:
+
+```diff
+ environment:
+ PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp enable-admin-console
+ PENPOT_PUBLIC_URI: http://localhost:9001
+ PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 367001600
+ PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600
+ PENPOT_SECRET_KEY: change-this-insecure-key
++
++ # Don't touch it; this uses an internal docker network to
++ # communicate with the admin-console.
++ PENPOT_ADMIN_CONSOLE_URI: http://penpot-admin-console:3000
+```
+
+#### Upgrade from version 1.x to 2.0
The migration to version 2.0, due to the incorporation of the new v2 components, includes
an additional process that runs automatically as soon as the application starts. If your
diff --git a/docs/technical-guide/getting-started/elestio.md b/docs/technical-guide/getting-started/elestio.md
index b6dc49af2a..15bef15bae 100644
--- a/docs/technical-guide/getting-started/elestio.md
+++ b/docs/technical-guide/getting-started/elestio.md
@@ -1,5 +1,5 @@
---
-title: 1.2 Install with Elestio
+title: 1.4 Install with Elestio
desc: "Step-by-step guide to deploy a self-hosted Penpot on Elestio: 3-minute setup, managed DNS/SMTP/SSL/backups, Docker Compose config, updates & support."
---
diff --git a/docs/technical-guide/getting-started/kubernetes.md b/docs/technical-guide/getting-started/kubernetes.md
index f2013083c6..1582d4015b 100644
--- a/docs/technical-guide/getting-started/kubernetes.md
+++ b/docs/technical-guide/getting-started/kubernetes.md
@@ -1,5 +1,5 @@
---
-title: 1.4 Install with Kubernetes
+title: 1.3 Install with Kubernetes
desc: Learn how to install and configure Penpot on your Kubernetes cluster using Helm. Our technical guide provides step-by-step instructions for setup.
---
diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs
index db6da2f22c..855e58d59d 100644
--- a/frontend/src/app/main/ui/auth/register.cljs
+++ b/frontend/src/app/main/ui/auth/register.cljs
@@ -21,7 +21,6 @@
[app.util.i18n :as i18n :refer [tr]]
[app.util.storage :as storage]
[beicon.v2.core :as rx]
- [cuerdas.core :as str]
[rumext.v2 :as mf]))
;; --- PAGE: Register
@@ -82,46 +81,43 @@
(mf/deps form)
(fn [cause]
(reset! submitted? false)
- (let [{:keys [type code] :as edata} (ex-data cause)]
+ (let [{:keys [type code] :as edata} (ex-data cause)
+
+ set-field-error!
+ (fn [field message]
+ (swap! form assoc-in [:extra-errors field] message)
+ (swap! form assoc-in [:touched field] true))]
+
(condp = [type code]
[:restriction :email-does-not-match-invitation]
- (st/emit! (ntf/error (tr "errors.email-does-not-match-invitation")))
+ (set-field-error! :email {:message (tr "errors.email-does-not-match-invitation")})
[:restriction :registration-disabled]
- (st/emit! (ntf/error (tr "errors.registration-disabled")))
+ (set-field-error! :email {:message (tr "errors.registration-disabled")})
[:restriction :email-domain-is-not-allowed]
- (st/emit! (ntf/error (tr "errors.email-domain-not-allowed")))
+ (set-field-error! :email {:message (tr "errors.email-domain-not-allowed")})
[:restriction :email-has-permanent-bounces]
- (st/emit! (ntf/error (tr "errors.email-has-permanent-bounces" (:email edata))))
+ (set-field-error! :email {:message (tr "errors.email-has-permanent-bounces" (:email edata))})
[:restriction :email-has-complaints]
- (st/emit! (ntf/error (tr "errors.email-has-permanent-bounces" (:email edata))))
+ (set-field-error! :email {:message (tr "errors.email-has-permanent-bounces" (:email edata))})
;; Reported on the email input itself, the way the recovery and
;; password forms report server side errors, so the field that
;; needs fixing is the one marked as invalid
[:validation :email-already-exists]
- (swap! form assoc-in [:extra-errors :email]
- {:message (tr "errors.email-already-exists")})
+ (set-field-error! :email {:message (tr "errors.email-already-exists")})
[:validation :email-as-password]
- (st/emit! (ntf/error (tr "errors.email-as-password")))
+ (set-field-error! :password {:message (tr "errors.email-as-password")})
[:validation :weak-password]
- (let [details (:details edata)
- items (when (seq details)
- (->> details
- (map #(str "" (tr %) ""))
- (str/join "")))
- detail (when items
- (str ""))]
- (st/emit! (ntf/show {:content (tr "errors.weak-password")
- :detail detail
- :is-html true
- :type :toast
- :level :error})))
+ (let [options (when (seq (:details edata))
+ (mapv tr (:details edata)))]
+ (set-field-error! :password {:message (tr "errors.weak-password")
+ :options options}))
(do
(when-let [explain (get edata :explain)]
@@ -133,7 +129,7 @@
(mf/deps on-success-callback)
(fn [params]
(if (fn? on-success-callback)
- (on-success-callback (:email params))
+ (on-success-callback params)
(cond
(some? (:invitation-token params))
(let [token (:invitation-token params)]
diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs
index 9a7dca3090..0adca2f01e 100644
--- a/frontend/src/app/main/ui/static.cljs
+++ b/frontend/src/app/main/ui/static.cljs
@@ -125,13 +125,22 @@
success-register
(mf/use-fn
(fn [data]
- (reset! register-token (:token data))
- (reset! current-section :register-validate)))
+ (cond
+ (:invitation-token data)
+ (st/emit! (rt/nav :auth-verify-token {:token (:invitation-token data)}))
+
+ (:is-active data)
+ (st/emit! (rt/reload true))
+
+ :else
+ (do
+ (reset! user-email (:email data))
+ (reset! current-section :register-email-sent)))))
register-email-sent
(mf/use-fn
- (fn [email]
- (reset! user-email email)
+ (fn [data]
+ (reset! user-email (if (string? data) data (:email data)))
(reset! current-section :register-email-sent)))
recovery-email-sent
@@ -355,7 +364,7 @@
[:span
(tr "labels.nitrate-not-configured.learn-more")
" "
- [:a {:href "https://help.penpot.app/technical-guide/getting-started/"
+ [:a {:href "https://help.penpot.app/technical-guide/getting-started/docker/#update-penpot"
:target "_blank"
:rel "noopener noreferrer"}
(tr "labels.nitrate-not-configured.technical-guide")]]]]
diff --git a/frontend/src/app/main/ui/viewer/login.cljs b/frontend/src/app/main/ui/viewer/login.cljs
index 436c35eef3..e99e7e3383 100644
--- a/frontend/src/app/main/ui/viewer/login.cljs
+++ b/frontend/src/app/main/ui/viewer/login.cljs
@@ -10,6 +10,7 @@
[app.common.logging :as log]
[app.config :as cf]
[app.main.data.modal :as modal]
+ [app.main.router :as rt]
[app.main.store :as st]
[app.main.ui.auth.login :refer [login-dialog*]]
[app.main.ui.auth.recovery-request :refer [recovery-request-page*]]
@@ -69,8 +70,17 @@
success-register
(fn [data]
- (reset! register-token (:token data))
- (set-current-section :register-validate))]
+ (cond
+ (:invitation-token data)
+ (st/emit! (rt/nav :auth-verify-token {:token (:invitation-token data)}))
+
+ (:is-active data)
+ (st/emit! (modal/hide))
+
+ :else
+ (do
+ (reset! user-email (:email data))
+ (set-current-section :email-sent))))]
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
diff --git a/scripts/gh.py b/scripts/gh.py
index f8b4f74d45..df4a4682e6 100755
--- a/scripts/gh.py
+++ b/scripts/gh.py
@@ -8,6 +8,7 @@ Subcommands:
issues List issues in a milestone (or unassigned with milestone=none)
prs Fetch details for one or more PRs (by number or milestone)
advisories List or inspect GitHub security advisories
+ link-issue Explicitly link a GitHub issue to a pull request
Usage:
python3 scripts/gh.py issues (default: state=closed)
@@ -27,6 +28,7 @@ Usage:
python3 scripts/gh.py advisories (list all advisories)
python3 scripts/gh.py advisories --severity critical (filter by severity)
python3 scripts/gh.py advisories GHSA-xvj6-fh9w-gjw7 (single advisory detail)
+ python3 scripts/gh.py link-issue 11235 11243
Prerequisites:
- gh CLI authenticated (gh auth status)
@@ -77,6 +79,141 @@ def run_gh_rest(path: str) -> Any:
return json.loads(result.stdout)
+# ─────────────────────────────────────────────
+# Subcommand: link-issue
+# ─────────────────────────────────────────────
+
+GQL_LINK_TARGETS_QUERY = """\
+query($owner: String!, $repo: String!, $issueNumber: Int!, $prNumber: Int!) {
+ repository(owner: $owner, name: $repo) {
+ issue(number: $issueNumber) { id number }
+ pullRequest(number: $prNumber) { id number }
+ }
+}
+"""
+
+GQL_ADD_CLOSE_ISSUE_REFERENCES = """\
+mutation($issueId: ID!, $pullRequestIds: [ID!]!) {
+ addCloseIssueReferences(input: {issueId: $issueId, pullRequestIds: $pullRequestIds}) {
+ issue { id number }
+ }
+}
+"""
+
+GQL_VERIFY_ISSUE_LINK_QUERY = """\
+query($owner: String!, $repo: String!, $issueNumber: Int!, $prNumber: Int!) {
+ repository(owner: $owner, name: $repo) {
+ issue(number: $issueNumber) {
+ number
+ state
+ closedByPullRequestsReferences(
+ includeClosedPrs: true
+ userLinkedOnly: true
+ first: 100
+ ) {
+ nodes { number state url }
+ }
+ }
+ pullRequest(number: $prNumber) {
+ number
+ state
+ closingIssuesReferences(first: 100) {
+ nodes { number state url }
+ }
+ }
+ }
+}
+"""
+
+
+def link_issue_to_pr(issue_number: int, pr_number: int) -> dict:
+ """Add and verify an explicit GitHub issue-to-PR link."""
+ if issue_number <= 0 or pr_number <= 0:
+ raise ValueError("issue and pull request numbers must be positive")
+
+ variables = {
+ "owner": OWNER,
+ "repo": REPO_NAME,
+ "issueNumber": issue_number,
+ "prNumber": pr_number,
+ }
+ target_data = run_gh_graphql(GQL_LINK_TARGETS_QUERY, variables)
+ repository = target_data.get("repository") or {}
+ issue = repository.get("issue") or {}
+ pull_request = repository.get("pullRequest") or {}
+ if not issue.get("id"):
+ raise RuntimeError(f"issue #{issue_number} was not found in {REPO}")
+ if not pull_request.get("id"):
+ raise RuntimeError(f"pull request #{pr_number} was not found in {REPO}")
+
+ mutation_data = run_gh_graphql(
+ GQL_ADD_CLOSE_ISSUE_REFERENCES,
+ {
+ "issueId": issue["id"],
+ "pullRequestIds": [pull_request["id"]],
+ },
+ )
+ mutation_result = mutation_data.get("addCloseIssueReferences") or {}
+ linked_issue = mutation_result.get("issue") or {}
+ if linked_issue.get("number") != issue_number:
+ raise RuntimeError(f"GitHub did not link issue #{issue_number}")
+
+ verification_data = run_gh_graphql(GQL_VERIFY_ISSUE_LINK_QUERY, variables)
+ repository = verification_data.get("repository") or {}
+ issue = repository.get("issue") or {}
+ pull_request = repository.get("pullRequest") or {}
+ if not issue or not pull_request:
+ raise RuntimeError("GitHub did not return both link targets during verification")
+
+ issue_links = [
+ node
+ for node in issue["closedByPullRequestsReferences"]["nodes"]
+ if node.get("number") == pr_number
+ ]
+ pr_links = [
+ node
+ for node in pull_request["closingIssuesReferences"]["nodes"]
+ if node.get("number") == issue_number
+ ]
+ if not issue_links or not pr_links:
+ raise RuntimeError(
+ f"issue #{issue_number} and pull request #{pr_number} are not linked"
+ )
+
+ return {
+ "linked": True,
+ "issue": {
+ "number": issue["number"],
+ "state": issue["state"],
+ "linked_pull_requests": issue_links,
+ },
+ "pull_request": {
+ "number": pull_request["number"],
+ "state": pull_request["state"],
+ "linked_issues": pr_links,
+ },
+ }
+
+
+def cmd_link_issue(args: argparse.Namespace) -> None:
+ """Handle the ``link-issue`` subcommand."""
+ print(
+ f"Linking issue #{args.issue_number} to pull request #{args.pr_number}...",
+ file=sys.stderr,
+ )
+ try:
+ result = link_issue_to_pr(args.issue_number, args.pr_number)
+ except (ValueError, RuntimeError) as error:
+ print(f"ERROR: {error}", file=sys.stderr)
+ sys.exit(1)
+
+ print(
+ f"Verified issue #{args.issue_number} -> pull request #{args.pr_number}",
+ file=sys.stderr,
+ )
+ print(json.dumps(result, indent=2))
+
+
# ─────────────────────────────────────────────
# Shared: milestone lookup
# ─────────────────────────────────────────────
@@ -764,6 +901,16 @@ def main() -> None:
)
p_prs.set_defaults(func=cmd_prs)
+ # --- link-issue ---
+ p_link = sub.add_parser(
+ "link-issue",
+ aliases=["link"],
+ help="Explicitly link an issue to a pull request and verify both sides",
+ )
+ p_link.add_argument("issue_number", type=int, help="Issue number")
+ p_link.add_argument("pr_number", type=int, help="Pull request number")
+ p_link.set_defaults(func=cmd_link_issue)
+
# --- advisories ---
p_adv = sub.add_parser("advisories", help="List or inspect GitHub security advisories")
p_adv.add_argument(
diff --git a/scripts/test_gh.py b/scripts/test_gh.py
new file mode 100644
index 0000000000..83e80ea5fd
--- /dev/null
+++ b/scripts/test_gh.py
@@ -0,0 +1,155 @@
+#!/usr/bin/env python3
+"""Tests for scripts/gh.py.
+
+Run with:
+
+ python3 scripts/test_gh.py
+"""
+
+import contextlib
+import importlib.machinery
+import importlib.util
+import io
+import json
+import pathlib
+import sys
+import types
+import unittest
+from unittest.mock import patch
+
+
+# Loading scripts/gh.py should not emit scripts/__pycache__/.
+sys.dont_write_bytecode = True
+
+SCRIPT_PATH = pathlib.Path(__file__).resolve().parent / "gh.py"
+
+
+def load_gh():
+ """Load scripts/gh.py as a module without running its CLI."""
+ loader = importlib.machinery.SourceFileLoader("gh_helper", str(SCRIPT_PATH))
+ spec = importlib.util.spec_from_loader("gh_helper", loader)
+ module = importlib.util.module_from_spec(spec)
+ loader.exec_module(module)
+ return module
+
+
+gh = load_gh()
+
+
+class LinkIssueTests(unittest.TestCase):
+ def setUp(self):
+ self.target_response = {
+ "repository": {
+ "issue": {"id": "issue-id", "number": 11235},
+ "pullRequest": {"id": "pr-id", "number": 11243},
+ }
+ }
+ self.mutation_response = {
+ "addCloseIssueReferences": {
+ "issue": {"id": "issue-id", "number": 11235}
+ }
+ }
+ self.verification_response = {
+ "repository": {
+ "issue": {
+ "number": 11235,
+ "state": "OPEN",
+ "closedByPullRequestsReferences": {
+ "nodes": [
+ {
+ "number": 11243,
+ "state": "MERGED",
+ "url": "https://github.com/penpot/penpot/pull/11243",
+ }
+ ]
+ },
+ },
+ "pullRequest": {
+ "number": 11243,
+ "state": "MERGED",
+ "closingIssuesReferences": {
+ "nodes": [
+ {
+ "number": 11235,
+ "state": "OPEN",
+ "url": "https://github.com/penpot/penpot/issues/11235",
+ }
+ ]
+ },
+ },
+ }
+ }
+
+ @patch.object(gh, "run_gh_graphql")
+ def test_link_issue_to_pr_adds_and_verifies_reference(self, run_graphql):
+ run_graphql.side_effect = [
+ self.target_response,
+ self.mutation_response,
+ self.verification_response,
+ ]
+
+ result = gh.link_issue_to_pr(11235, 11243)
+
+ self.assertTrue(result["linked"])
+ self.assertEqual(
+ result["issue"]["linked_pull_requests"][0]["number"],
+ 11243,
+ )
+ self.assertEqual(
+ result["pull_request"]["linked_issues"][0]["number"],
+ 11235,
+ )
+ self.assertEqual(run_graphql.call_count, 3)
+ self.assertEqual(
+ run_graphql.call_args_list[1].args[1],
+ {"issueId": "issue-id", "pullRequestIds": ["pr-id"]},
+ )
+
+ @patch.object(gh, "run_gh_graphql")
+ def test_link_issue_to_pr_fails_when_verification_is_missing(self, run_graphql):
+ self.verification_response["repository"]["issue"][
+ "closedByPullRequestsReferences"
+ ]["nodes"] = []
+ run_graphql.side_effect = [
+ self.target_response,
+ self.mutation_response,
+ self.verification_response,
+ ]
+
+ with self.assertRaisesRegex(RuntimeError, "are not linked"):
+ gh.link_issue_to_pr(11235, 11243)
+
+ @patch.object(gh, "link_issue_to_pr")
+ def test_cmd_link_issue_outputs_verified_result(self, link_issue):
+ expected = {
+ "linked": True,
+ "issue": {"number": 11235, "state": "OPEN"},
+ "pull_request": {"number": 11243, "state": "MERGED"},
+ }
+ link_issue.return_value = expected
+ args = types.SimpleNamespace(issue_number=11235, pr_number=11243)
+ stdout = io.StringIO()
+ stderr = io.StringIO()
+
+ with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
+ gh.cmd_link_issue(args)
+
+ self.assertEqual(json.loads(stdout.getvalue()), expected)
+ self.assertIn("Verified issue #11235", stderr.getvalue())
+ link_issue.assert_called_once_with(11235, 11243)
+
+ @patch.object(gh, "link_issue_to_pr", side_effect=RuntimeError("link missing"))
+ def test_cmd_link_issue_fails_when_verification_is_missing(self, _link_issue):
+ args = types.SimpleNamespace(issue_number=11235, pr_number=11243)
+ stderr = io.StringIO()
+
+ with contextlib.redirect_stderr(stderr):
+ with self.assertRaises(SystemExit) as error:
+ gh.cmd_link_issue(args)
+
+ self.assertEqual(error.exception.code, 1)
+ self.assertIn("link missing", stderr.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)