From 7f95439bc22cdf5158e69bea60ed3c41122e12f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 1 Oct 2026 08:40:42 +0000 Subject: [PATCH 01/14] :construction_worker: Declare least-privilege workflow permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an explicit permissions block to every workflow that relied on the repository default. Entry points that only call reusable workflows get permissions: {} and grant each call what the called workflow needs: contents: read for the bundle and docker builds, contents: write for the release, nothing for the admin-console dispatch (it uses its own token). Workflows that check out code get contents: read; the commit checker also gets pull-requests: read to list the PR commits. Signed-off-by: David Barragán Merino --- .github/workflows/auto-label.yml | 2 ++ .github/workflows/build-adhoc.yml | 7 ++++++ .github/workflows/build-bundle.yml | 3 +++ .github/workflows/build-develop.yml | 7 ++++++ .../workflows/build-docker-admin-console.yml | 2 ++ .github/workflows/build-docker-devenv.yml | 3 +++ .github/workflows/build-docker.yml | 3 +++ .github/workflows/build-staging.yml | 7 ++++++ .github/workflows/build-tag.yml | 10 ++++++++ .github/workflows/build-tmp-tokens.yml | 6 +++++ .github/workflows/commit-checker.yml | 4 ++++ .github/workflows/plugins-deploy-packages.yml | 3 +++ .github/workflows/tests-backend.yml | 3 +++ .github/workflows/tests-common.yml | 3 +++ .github/workflows/tests-e2e.yml | 3 +++ .github/workflows/tests-exporter.yml | 3 +++ .github/workflows/tests-frontend.yml | 3 +++ .github/workflows/tests-library.yml | 3 +++ .github/workflows/tests-mcp.yml | 3 +++ .github/workflows/tests-plugins.yml | 3 +++ .github/workflows/tests-wasm.yml | 3 +++ .github/zizmor.yml | 24 ------------------- 22 files changed, 84 insertions(+), 24 deletions(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 6b32b61c52..7bbfba22fb 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -6,6 +6,8 @@ on: pull_request_target: types: [opened] +permissions: {} + jobs: triage: runs-on: ubuntu-latest diff --git a/.github/workflows/build-adhoc.yml b/.github/workflows/build-adhoc.yml index f5f9b88854..1dcb83bbb5 100644 --- a/.github/workflows/build-adhoc.yml +++ b/.github/workflows/build-adhoc.yml @@ -20,8 +20,12 @@ on: required: false default: false +permissions: {} + jobs: build-bundle: + permissions: + contents: read uses: ./.github/workflows/build-bundle.yml secrets: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -35,6 +39,8 @@ jobs: build-docker: needs: build-bundle + permissions: + contents: read uses: ./.github/workflows/build-docker.yml secrets: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -54,6 +60,7 @@ jobs: force: ${{ inputs.force }} build-docker-admin-console: + permissions: {} uses: ./.github/workflows/build-docker-admin-console.yml secrets: ORG_WORKFLOW_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }} diff --git a/.github/workflows/build-bundle.yml b/.github/workflows/build-bundle.yml index 752ff096f6..1172790408 100644 --- a/.github/workflows/build-bundle.yml +++ b/.github/workflows/build-bundle.yml @@ -49,6 +49,9 @@ concurrency: group: build-bundle-${{ inputs.gh_ref }} cancel-in-progress: true +permissions: + contents: read + jobs: # ── 1. Decide whether there is anything to build ─────────────────────── check: diff --git a/.github/workflows/build-develop.yml b/.github/workflows/build-develop.yml index 942f840576..e808f38384 100644 --- a/.github/workflows/build-develop.yml +++ b/.github/workflows/build-develop.yml @@ -18,8 +18,12 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: true +permissions: {} + jobs: build-bundle: + permissions: + contents: read uses: ./.github/workflows/build-bundle.yml secrets: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -33,6 +37,8 @@ jobs: build-docker: needs: build-bundle + permissions: + contents: read uses: ./.github/workflows/build-docker.yml secrets: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -52,6 +58,7 @@ jobs: force: ${{ inputs.force || false }} build-docker-admin-console: + permissions: {} uses: ./.github/workflows/build-docker-admin-console.yml secrets: ORG_WORKFLOW_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }} diff --git a/.github/workflows/build-docker-admin-console.yml b/.github/workflows/build-docker-admin-console.yml index e193d47704..3d1ef04008 100644 --- a/.github/workflows/build-docker-admin-console.yml +++ b/.github/workflows/build-docker-admin-console.yml @@ -39,6 +39,8 @@ on: description: 'Token with Actions write access on penpot-nitrate' required: true +permissions: {} + jobs: build-nitrate-docker: runs-on: ubuntu-latest diff --git a/.github/workflows/build-docker-devenv.yml b/.github/workflows/build-docker-devenv.yml index 39d3c2594f..87856225bb 100644 --- a/.github/workflows/build-docker-devenv.yml +++ b/.github/workflows/build-docker-devenv.yml @@ -3,6 +3,9 @@ name: DevEnv Docker Image Builder on: workflow_dispatch: +permissions: + contents: read + jobs: build-and-push: name: Build and push DevEnv Docker image diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index f7348a0998..a590606585 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -70,6 +70,9 @@ env: # missing (e.g. if runners ever move to separate machines). BUNDLE_CACHE: /var/tmp/penpot-bundle-cache +permissions: + contents: read + jobs: # ── 1. Resolve the build key and check the whole set at once ─────────── prepare: diff --git a/.github/workflows/build-staging.yml b/.github/workflows/build-staging.yml index 3fb417c801..2b8d7a8e4c 100644 --- a/.github/workflows/build-staging.yml +++ b/.github/workflows/build-staging.yml @@ -18,8 +18,12 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: true +permissions: {} + jobs: build-bundle: + permissions: + contents: read uses: ./.github/workflows/build-bundle.yml secrets: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -33,6 +37,8 @@ jobs: build-docker: needs: build-bundle + permissions: + contents: read uses: ./.github/workflows/build-docker.yml secrets: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -52,6 +58,7 @@ jobs: force: ${{ inputs.force || false }} build-docker-admin-console: + permissions: {} uses: ./.github/workflows/build-docker-admin-console.yml secrets: ORG_WORKFLOW_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }} diff --git a/.github/workflows/build-tag.yml b/.github/workflows/build-tag.yml index 9b0b0fcbb3..c2fd48671d 100644 --- a/.github/workflows/build-tag.yml +++ b/.github/workflows/build-tag.yml @@ -21,8 +21,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref_name }} cancel-in-progress: false +permissions: {} + jobs: build-bundle: + permissions: + contents: read uses: ./.github/workflows/build-bundle.yml secrets: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -36,6 +40,8 @@ jobs: build-docker: needs: build-bundle + permissions: + contents: read uses: ./.github/workflows/build-docker.yml secrets: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -53,6 +59,7 @@ jobs: force: ${{ inputs.force || false }} build-docker-admin-console: + permissions: {} uses: ./.github/workflows/build-docker-admin-console.yml secrets: ORG_WORKFLOW_TOKEN: ${{ secrets.ORG_WORKFLOW_TOKEN }} @@ -62,6 +69,7 @@ jobs: notify: name: Notifications + permissions: {} runs-on: ubuntu-latest needs: - build-docker @@ -82,6 +90,8 @@ jobs: needs: - build-docker - build-docker-admin-console + permissions: + contents: write uses: ./.github/workflows/release.yml secrets: AWS_REGION: ${{ secrets.AWS_REGION }} diff --git a/.github/workflows/build-tmp-tokens.yml b/.github/workflows/build-tmp-tokens.yml index 68eeae1706..28802b1ccc 100644 --- a/.github/workflows/build-tmp-tokens.yml +++ b/.github/workflows/build-tmp-tokens.yml @@ -9,8 +9,12 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: true +permissions: {} + jobs: build-bundle: + permissions: + contents: read uses: ./.github/workflows/build-bundle.yml secrets: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -23,6 +27,8 @@ jobs: build-docker: needs: build-bundle + permissions: + contents: read uses: ./.github/workflows/build-docker.yml secrets: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index c876d10160..bd4a9f0572 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -20,6 +20,10 @@ on: - develop - staging +permissions: + contents: read + pull-requests: read + jobs: check-commit-message: if: ${{ !github.event.pull_request.draft }} diff --git a/.github/workflows/plugins-deploy-packages.yml b/.github/workflows/plugins-deploy-packages.yml index 218620983f..11650fd5ef 100644 --- a/.github/workflows/plugins-deploy-packages.yml +++ b/.github/workflows/plugins-deploy-packages.yml @@ -21,6 +21,9 @@ on: - staging - main +permissions: + contents: read + jobs: detect-changes: runs-on: ubuntu-latest diff --git a/.github/workflows/tests-backend.yml b/.github/workflows/tests-backend.yml index 1557a8685a..6a447d5737 100644 --- a/.github/workflows/tests-backend.yml +++ b/.github/workflows/tests-backend.yml @@ -28,6 +28,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-backend: if: ${{ !github.event.pull_request.draft }} diff --git a/.github/workflows/tests-common.yml b/.github/workflows/tests-common.yml index b622958277..a4d32c39b1 100644 --- a/.github/workflows/tests-common.yml +++ b/.github/workflows/tests-common.yml @@ -26,6 +26,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-common: if: ${{ !github.event.pull_request.draft }} diff --git a/.github/workflows/tests-e2e.yml b/.github/workflows/tests-e2e.yml index 07e1cc47a9..0f6f0f8765 100644 --- a/.github/workflows/tests-e2e.yml +++ b/.github/workflows/tests-e2e.yml @@ -86,6 +86,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || inputs.gh_ref || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: # ── 1. Build the frontend bundle once ────────────────────────────────── build-bundle: diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml index 8441af3a75..db32e3f59c 100644 --- a/.github/workflows/tests-exporter.yml +++ b/.github/workflows/tests-exporter.yml @@ -30,6 +30,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-exporter: if: ${{ !github.event.pull_request.draft }} diff --git a/.github/workflows/tests-frontend.yml b/.github/workflows/tests-frontend.yml index 8fcfdc8066..eff25d11df 100644 --- a/.github/workflows/tests-frontend.yml +++ b/.github/workflows/tests-frontend.yml @@ -30,6 +30,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-frontend: if: ${{ !github.event.pull_request.draft }} diff --git a/.github/workflows/tests-library.yml b/.github/workflows/tests-library.yml index df2c478ca1..5a714705fb 100644 --- a/.github/workflows/tests-library.yml +++ b/.github/workflows/tests-library.yml @@ -28,6 +28,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-library: if: ${{ !github.event.pull_request.draft }} diff --git a/.github/workflows/tests-mcp.yml b/.github/workflows/tests-mcp.yml index 68ff0584bd..50dc8a6806 100644 --- a/.github/workflows/tests-mcp.yml +++ b/.github/workflows/tests-mcp.yml @@ -24,6 +24,9 @@ on: paths: - 'mcp/**' +permissions: + contents: read + jobs: test-mcp: if: ${{ !github.event.pull_request.draft }} diff --git a/.github/workflows/tests-plugins.yml b/.github/workflows/tests-plugins.yml index 180f125489..01ed31eccf 100644 --- a/.github/workflows/tests-plugins.yml +++ b/.github/workflows/tests-plugins.yml @@ -26,6 +26,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-plugins: if: ${{ !github.event.pull_request.draft }} diff --git a/.github/workflows/tests-wasm.yml b/.github/workflows/tests-wasm.yml index fdbc8820ff..135d65a045 100644 --- a/.github/workflows/tests-wasm.yml +++ b/.github/workflows/tests-wasm.yml @@ -26,6 +26,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: test-render-wasm: if: ${{ !github.event.pull_request.draft }} diff --git a/.github/zizmor.yml b/.github/zizmor.yml index d4681e9f17..fa7ba76bf6 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -22,30 +22,6 @@ rules: ignore: - auto-label.yml - commit-checker.yml - # Declare least-privilege `permissions:` per workflow/job. - excessive-permissions: - ignore: - - auto-label.yml - - build-adhoc.yml - - build-bundle.yml - - build-develop.yml - - build-docker-admin-console.yml - - build-docker-devenv.yml - - build-docker.yml - - build-staging.yml - - build-tag.yml - - build-tmp-tokens.yml - - commit-checker.yml - - plugins-deploy-packages.yml - - tests-backend.yml - - tests-common.yml - - tests-e2e.yml - - tests-exporter.yml - - tests-frontend.yml - - tests-library.yml - - tests-mcp.yml - - tests-plugins.yml - - tests-wasm.yml # Scope GitHub App tokens. github-app: ignore: From 214cbbf678a7ca0fab46f384febf3a5d5410eae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 1 Oct 2026 08:40:43 +0000 Subject: [PATCH 02/14] :construction_worker: Drop pull_request_target from commit checker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job only reads commit messages, which works with the pull_request trigger for PRs from forks too. Running it on both triggers checked every PR twice, once with a privileged token it does not need. Signed-off-by: David Barragán Merino --- .github/workflows/commit-checker.yml | 7 ------- .github/zizmor.yml | 1 - 2 files changed, 8 deletions(-) diff --git a/.github/workflows/commit-checker.yml b/.github/workflows/commit-checker.yml index bd4a9f0572..29d9c79d60 100644 --- a/.github/workflows/commit-checker.yml +++ b/.github/workflows/commit-checker.yml @@ -7,13 +7,6 @@ on: - reopened - synchronize - ready_for_review - pull_request_target: - types: - - opened - - edited - - reopened - - synchronize - - ready_for_review push: branches: - main diff --git a/.github/zizmor.yml b/.github/zizmor.yml index fa7ba76bf6..6bcbaebf0a 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -21,7 +21,6 @@ rules: dangerous-triggers: ignore: - auto-label.yml - - commit-checker.yml # Scope GitHub App tokens. github-app: ignore: From 46d64da93126e2a83ac8c046f22a35c63c504031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 1 Oct 2026 08:40:44 +0000 Subject: [PATCH 03/14] :construction_worker: Scope the triage GitHub App token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request a token for this repository only, instead of every repository in the installation, and only with the permissions the job uses: issues (labels) and organization projects (the board). Also document why the workflow needs pull_request_target and why it is safe. Signed-off-by: David Barragán Merino --- .github/workflows/auto-label.yml | 8 +++++++- .github/zizmor.yml | 4 ---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 7bbfba22fb..7d50d52a88 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -1,5 +1,8 @@ name: Auto Label and Add to Project +# pull_request_target is required so the GitHub App secrets are available +# for PRs from forks. It is safe here: the job never checks out or runs PR +# code, it only reads ids from the event payload. on: issues: types: [opened] @@ -18,7 +21,10 @@ jobs: with: app-id: ${{ secrets.TRIAGE_APP_ID }} private-key: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }} - owner: penpot + # No owner/repositories: the token is scoped to this repository. + # Organization permissions (the project board) still apply. + permission-issues: write + permission-organization-projects: write - name: Process Issue or PR uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 6bcbaebf0a..645e3764d8 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -21,10 +21,6 @@ rules: dangerous-triggers: ignore: - auto-label.yml - # Scope GitHub App tokens. - github-app: - ignore: - - auto-label.yml # Style nudge towards the `$/...` syntax; not worth enforcing. self-repository: disable: true From 8c1a813b143837115d4a36f233d13a4783b7c24f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 1 Oct 2026 08:40:45 +0000 Subject: [PATCH 04/14] :construction_worker: Drop dependency cache from plugin doc deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy workflows must not restore a cache that other workflows can write to. The pnpm store cache is removed from the api-doc and styles-doc deploys; installs are slower but deploys are infrequent. Also install with --frozen-lockfile in the three plugin deploys, as CI already does by default, so a deploy never resolves dependencies that differ from the lockfile. Signed-off-by: David Barragán Merino --- .github/workflows/plugins-deploy-api-doc.yml | 20 +++---------------- .github/workflows/plugins-deploy-package.yml | 2 +- .../workflows/plugins-deploy-styles-doc.yml | 20 +++---------------- .github/zizmor.yml | 5 ----- 4 files changed, 7 insertions(+), 40 deletions(-) diff --git a/.github/workflows/plugins-deploy-api-doc.yml b/.github/workflows/plugins-deploy-api-doc.yml index a3598af4bd..4d0e5754d2 100644 --- a/.github/workflows/plugins-deploy-api-doc.yml +++ b/.github/workflows/plugins-deploy-api-doc.yml @@ -45,7 +45,8 @@ jobs: fetch-depth: 0 ref: ${{ steps.vars.outputs.gh_ref }} - # START: Setup Node and PNPM enabling cache + # No dependency cache here: a deploy must not restore a cache that + # other workflows can write to. - name: Setup pnpm + Node.js uses: pnpm/setup@84cb39b217b10273981911c288cd62326dc7c6d2 # v2.0.2 with: @@ -53,26 +54,11 @@ jobs: runtime: node@24.21.0 install: false - - name: Get pnpm store path - id: pnpm-store - working-directory: ./plugins - shell: bash - run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - - - name: Cache pnpm store - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: ${{ steps.pnpm-store.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - # END: Setup Node and PNPM enabling cache - - name: Install deps working-directory: ./plugins shell: bash run: | - pnpm install --no-frozen-lockfile; + pnpm install --frozen-lockfile; pnpm add -D -w wrangler@latest; - name: Build docs diff --git a/.github/workflows/plugins-deploy-package.yml b/.github/workflows/plugins-deploy-package.yml index 7c83df9f5d..6165dd465b 100644 --- a/.github/workflows/plugins-deploy-package.yml +++ b/.github/workflows/plugins-deploy-package.yml @@ -75,7 +75,7 @@ jobs: working-directory: ./plugins shell: bash run: | - pnpm install --no-frozen-lockfile; + pnpm install --frozen-lockfile; pnpm add -D -w wrangler@latest; - name: "Build package for ${{ inputs.plugin_name }}-plugin" diff --git a/.github/workflows/plugins-deploy-styles-doc.yml b/.github/workflows/plugins-deploy-styles-doc.yml index 3c04eb6b58..0f04b62615 100644 --- a/.github/workflows/plugins-deploy-styles-doc.yml +++ b/.github/workflows/plugins-deploy-styles-doc.yml @@ -43,7 +43,8 @@ jobs: fetch-depth: 0 ref: ${{ steps.vars.outputs.gh_ref }} - # START: Setup Node and PNPM enabling cache + # No dependency cache here: a deploy must not restore a cache that + # other workflows can write to. - name: Setup pnpm + Node.js uses: pnpm/setup@84cb39b217b10273981911c288cd62326dc7c6d2 # v2.0.2 with: @@ -51,26 +52,11 @@ jobs: runtime: node@24.21.0 install: false - - name: Get pnpm store path - id: pnpm-store - working-directory: ./plugins - shell: bash - run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT - - - name: Cache pnpm store - uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 - with: - path: ${{ steps.pnpm-store.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-${{ hashFiles('plugins/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - # END: Setup Node and PNPM enabling cache - - name: Install deps working-directory: ./plugins shell: bash run: | - pnpm install --no-frozen-lockfile; + pnpm install --frozen-lockfile; pnpm add -D -w wrangler@latest; - name: Build styles diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 645e3764d8..b50d0fdffd 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -12,11 +12,6 @@ rules: - plugins-deploy-api-doc.yml - plugins-deploy-package.yml - plugins-deploy-styles-doc.yml - # Avoid restoring caches in release/publish workflows. - cache-poisoning: - ignore: - - plugins-deploy-api-doc.yml - - plugins-deploy-styles-doc.yml # Review `pull_request_target` usage. dangerous-triggers: ignore: From 571a8805e68a53884b555b42908965b6311c4586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 1 Oct 2026 08:40:45 +0000 Subject: [PATCH 05/14] :construction_worker: Document remaining zizmor exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark each remaining ignore in zizmor.yml as a pending clean-up or an accepted risk, with its reason. Signed-off-by: David Barragán Merino --- .github/zizmor.yml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/zizmor.yml b/.github/zizmor.yml index b50d0fdffd..f86f018b5d 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,25 +1,31 @@ # zizmor configuration — https://docs.zizmor.sh/configuration/ # -# Every audit is enforced for every workflow. The per-file ignores below are -# the findings that already existed when this check was introduced: each one -# is a pending clean-up, not an accepted risk. New workflows are checked -# with no exceptions. Remove a file from a list in the same PR that fixes it, -# and the whole rule entry once its list is empty. +# Every audit is enforced for every workflow. New workflows are checked with +# no exceptions. The entries below are the only remaining ones, and each is +# marked as either a pending clean-up or an accepted risk with its reason. +# Remove a file from a list in the same PR that fixes it, and the whole rule +# entry once its list is empty. rules: - # Avoid installing packages ad hoc inside `run:`. + # Pending: wrangler is added with `pnpm add wrangler@latest` before each + # deploy because it is not a dependency of the plugins workspace. Fixed by + # adding it to plugins/package.json (devDependencies) and dropping that line. adhoc-packages: ignore: - plugins-deploy-api-doc.yml - plugins-deploy-package.yml - plugins-deploy-styles-doc.yml - # Review `pull_request_target` usage. + # Accepted: auto-label.yml needs pull_request_target so the GitHub App + # secrets are available for PRs from forks. It never checks out or runs + # PR code, it only reads ids from the event payload. dangerous-triggers: ignore: - auto-label.yml # Style nudge towards the `$/...` syntax; not worth enforcing. self-repository: disable: true - # Pin container images to a digest. + # Accepted for now: penpotapp/devenv is our own image, built and pushed by + # build-docker-devenv.yml. Pinning it by digest means bumping ten files on + # every rebuild; revisit once Renovate can do that automatically. unpinned-images: ignore: - plugins-deploy-package.yml From c8ef223f67f284422a53d1ed63d744848a2175da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Fri, 2 Oct 2026 09:58:38 +0200 Subject: [PATCH 06/14] :bug: Fix organization/team switcher ui (#12019) --- frontend/src/app/main/ui/dashboard/libraries.scss | 4 ---- .../main/ui/dashboard/organization_team_switch.cljs | 6 +++--- .../main/ui/dashboard/organization_team_switch.scss | 11 ++++++++--- frontend/src/app/main/ui/dashboard/projects.scss | 4 ---- frontend/src/app/main/ui/dashboard/search.scss | 4 ---- 5 files changed, 11 insertions(+), 18 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/libraries.scss b/frontend/src/app/main/ui/dashboard/libraries.scss index adf09d7af1..ae28f34484 100644 --- a/frontend/src/app/main/ui/dashboard/libraries.scss +++ b/frontend/src/app/main/ui/dashboard/libraries.scss @@ -17,8 +17,4 @@ &.dashboard-projects { user-select: none; } - - &.search { - margin-top: deprecated.$s-12; - } } diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs b/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs index c07ba7f6b1..9d99ecb3c0 100644 --- a/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch.cljs @@ -220,7 +220,7 @@ [:> raw-svg* {:id penpot-logo-icon-subtle}]] [:> organization-avatar* {:organization organization :size "xxl"}]) [:span {:class (stl/css :organization-text-group)} - [:span {:class (stl/css :organization-text) + [:span {:class (stl/css-case :organization-text true :organization-current (= bucket-id current-id)) :title (if personal? (tr "dashboard.other-teams") (:name organization))} (if personal? (tr "dashboard.other-teams") (:name organization))] (when (= bucket-id current-id) @@ -289,7 +289,7 @@ :href admin-console-href :tab-index "-1"} [:span {:class (stl/css :icon-wrapper)} - [:> icon* {:icon-id i/arrow-up-right :class (stl/css :action-icon)}]] + [:> icon* {:size "s" :icon-id i/arrow-up-right :class (stl/css :action-icon)}]] [:span {:class (stl/css :organization-text)} (tr "dashboard.go-to-admin-console")]]]) [:> dropdown-menu-item* {:on-click on-create-organization @@ -319,7 +319,7 @@ :class (stl/css :team-item-picture) :alt (:name team)}]) [:span {:class (stl/css :team-text-group)} - [:span {:class (stl/css :team-text) + [:span {:class (stl/css-case :team-text true :team-current (= (:id team) selected-team-id)) :title (team-display-name team)} (team-display-name team)] (when show-badge? diff --git a/frontend/src/app/main/ui/dashboard/organization_team_switch.scss b/frontend/src/app/main/ui/dashboard/organization_team_switch.scss index fa6bb851f5..c2aef5e875 100644 --- a/frontend/src/app/main/ui/dashboard/organization_team_switch.scss +++ b/frontend/src/app/main/ui/dashboard/organization_team_switch.scss @@ -198,7 +198,7 @@ padding: var(--sp-s); border-radius: $br-8; z-index: var(--z-index-dropdown); - color: var(--title-foreground-color-hover); + color: var(--color-foreground-secondary); background-color: var(--switcher-dropdown-background); border: $b-2 solid var(--panel-border-color); margin: 0; @@ -261,7 +261,6 @@ .column-label { @include t.use-typography("headline-small"); - color: var(--color-foreground-secondary); padding: var(--sp-s) $sz-6 $sz-6 var(--sp-m); margin-block-end: var(--sp-xxs); text-transform: uppercase; @@ -331,6 +330,7 @@ &:hover { background-color: var(--menu-background-color-hover); + color: var(--color-foreground-primary); } } @@ -366,7 +366,6 @@ min-width: 0; text-align: left; - color: var(--menu-foreground-color-hover); } .organization-text, @@ -376,6 +375,12 @@ .selected { background-color: var(--menu-background-color-hover); + color: var(--color-foreground-primary); +} + +.organization-current, +.team-current { + color: var(--color-foreground-primary); } .action { diff --git a/frontend/src/app/main/ui/dashboard/projects.scss b/frontend/src/app/main/ui/dashboard/projects.scss index e60171caea..7eeccbcfb3 100644 --- a/frontend/src/app/main/ui/dashboard/projects.scss +++ b/frontend/src/app/main/ui/dashboard/projects.scss @@ -35,10 +35,6 @@ margin-inline-end: px2rem(52); } -.search { - margin-block-start: var(--sp-m); -} - .dashboard-project-row { --actions-opacity: 0; diff --git a/frontend/src/app/main/ui/dashboard/search.scss b/frontend/src/app/main/ui/dashboard/search.scss index 8eea1a9f6e..4848eeaa97 100644 --- a/frontend/src/app/main/ui/dashboard/search.scss +++ b/frontend/src/app/main/ui/dashboard/search.scss @@ -23,10 +23,6 @@ width: calc(100vw - deprecated.$s-320); margin-right: deprecated.$s-52; } - - &.search { - margin-top: deprecated.$s-12; - } } .grid-empty-placeholder.search { From ea3d18c0a563b4ae801568ab422ffb39f548aac4 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 2 Oct 2026 11:08:13 +0200 Subject: [PATCH 07/14] :sparkles: Audit-log docs, error-reporting flag and frontend initiator (#11997) * :sparkles: Gate error reporters behind :error-reporting flag Error reporters used to start on every boot with no way to switch them off. They now only start when the new :error-reporting flag is on (off by default, enable with PENPOT_FLAGS=enable-error-reporting). The database reporter needs the flag alone; mattermost needs the flag plus its webhook url. Shutdown with the flag off is safe, and the database loop now honors its runtime switch. AI-assisted-by: muse-spark-1.3-contributor * :books: Add audit-log reference memory and update dev tooling Adds the top-level audit-log reference memory with all event flows and turns backend/audit-log into a redirect stub. Also includes the staged dev-tooling updates (playwright deps, mdts args, gitignore). AI-assisted-by: muse-spark-1.3-contributor * :sparkles: Assign server-side initiator to frontend audit events Frontend audit events now carry a server-assigned initiator derived from the x-client header (penpot-frontend goes to app, penpot-admin-console goes to admin-console, legacy penpot-nitrate stays on admin-console, all else goes to app). Client-sent values are always overwritten, and the key survives on telemetry shadow rows. AI-assisted-by: muse-spark-1.3-contributor * :bug: Keep initiator on frontend telemetry context The strips-pii-keys test still asserted the old contract (initiator stripped). The initiator only names the sending app, so it is not personal data and must survive the telemetry filter like on the backend channel. AI-assisted-by: muse-spark-1.3-contributor * :books: Document initiator on PostHog and browser origins Complete the audit-log memory for the latest changes: the initiator property forwarded to PostHog on both channels and the eventOrigin rule for admin-console browser events. AI-assisted-by: muse-spark-1.3-contributor * :bug: Fix unscaped argument in script --------- Co-authored-by: alonso.torres --- .gitignore | 1 + .serena/memories/audit-log.md | 321 +++++++++++ .serena/memories/backend/audit-log.md | 53 +- .serena/memories/backend/core.md | 2 +- backend/src/app/loggers/audit.clj | 19 + backend/src/app/loggers/database.clj | 64 +-- backend/src/app/loggers/mattermost.clj | 6 +- backend/src/app/rpc/commands/audit.clj | 37 +- .../loggers_error_reporters_test.clj | 109 ++++ backend/test/backend_tests/rpc_audit_test.clj | 52 ++ .../backend_tests/tasks_telemetry_test.clj | 4 +- common/src/app/common/flags.cljc | 4 + package.json | 14 +- pnpm-lock.yaml | 498 +++++++++--------- pnpm-workspace.yaml | 3 - scripts/start-mdts-server | 2 +- 16 files changed, 823 insertions(+), 366 deletions(-) create mode 100644 .serena/memories/audit-log.md create mode 100644 backend/test/backend_tests/loggers_error_reporters_test.clj diff --git a/.gitignore b/.gitignore index 7d4f91e50b..5d3cbd93ef 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,4 @@ opencode.json /.ci-logs /.codex/ /tools/__pycache__ +/scripts/__pycache__ diff --git a/.serena/memories/audit-log.md b/.serena/memories/audit-log.md new file mode 100644 index 0000000000..8097bd901c --- /dev/null +++ b/.serena/memories/audit-log.md @@ -0,0 +1,321 @@ +# Audit Log and event processing + +Complete guide to the Penpot event system: what gets recorded, where each piece lives, and how events travel from the moment they happen until they are archived. Start with the overview (sections 1-3); the deeper technical detail comes later (section 8 on). If you only want the idea, the first three sections are enough. + +> Note about this memory: unlike other Serena memories (dense notes written for LLM agents, see `mem:memory-maintenance`), this document is also written for human developers who need full context. That is why it uses plain language and examples. It replaces the old `mem:backend/audit-log`, which is now just a redirect stub. + +## 1. What it is (2-minute overview) + +The *audit log* answers "who did what, when, and from where". Every important user action becomes an **event**: a small object with a name, a type, who did it, from which IP, plus action data (`props`) and metadata (`context`). + +The system has: + +- **Three producers** (they create events): the Penpot backend (RPC layer), the Penpot frontend (browser app), and the Nitrate admin-console, which sends both server-side and client-side events. +- **One central buffer**: the Postgres `audit_log` table in Penpot. It is temporary: it holds events for minutes/hours until they are archived, then they are deleted. +- **Four consumers** (they read events): webhooks, error reporters, telemetry, and the external archive in Nexus. +- **One long-term archive**: **Nexus**, a separate service that stores events for the long term. Archiving to **Nitrate** is in development and **does not exist on the current branch** (see section 11). + +``` +Penpot frontend ─ POST push-audit-events (public) ─────────▶┐ +Nitrate admin-console (browser) ─ POST push-audit-events ──▶│ +Nitrate admin-console (server) ─ POST push-audit-events ──▶│ ─▶ audit_log table (buffer) ── cron every 5m ──▶ Nexus (archive) +Penpot backend (RPC wrap-audit) ─ submit ───────────────────┘ │ ├──▶ webhooks + │ ├──▶ error reporters + └──▶ telemetry (shadow rows) +``` + +The Penpot backend is the **center**: it checks, completes, and stores every event, no matter where it comes from. Nobody writes to the database directly; everything goes through the backend. + +This document is the reference for all flows. One thing to keep in mind while reading: the Nitrate admin-console (both its server and its browser UI) is a **private** application, not a public client. Its events describe private SaaS management actions (organizations, subscriptions, SSO), and its server channel uses a trusted shared key instead of a user session. + +## 2. The three kinds of events + +Not all events are worth the same. This is the most important distinction in this document: + +| Aspect | Backend events | Penpot frontend events | Nitrate admin-console events | +|---|---|---|---| +| What they describe | Real actions run on the server (create file, login, invite to team...) | Things that happen in the Penpot browser app (navigate, edit, JS errors, performance) | Organization and subscription actions in the admin-console (create/rename/delete organization, members, SSO settings, checkout, onboarding) | +| Trust level | **High**: created inside the same request that runs the action | **Best-effort**: some loss is expected (tab closed, offline, full buffer); they show **usage**, they are not strict proof | **Mixed**: server-side events are high trust (like backend); browser events are best-effort (like frontend) | +| Who sets `profile-id` | The server, from the logged-in session (never trusts the client) | The server too: it ignores what the client says and uses the session | Server channel: the trusted admin-console server sends it explicitly. Browser channel: the Penpot server uses the session | +| Who sets the time | Server time (`created-at`); the source of truth | Client proposes `tracked-at`, but the server fixes it if it is in the future or more than 1h late | Same rule: server stamps `created-at`; client `tracked-at` is corrected | +| Used for | Audit, webhooks, compliance, abuse checks | Product analytics, usage counters, crash reports | Organization audit trail (admin-console e2e spec), subscription metrics | + +Rule of thumb: if you need to know for sure "did this user do X?", look at `source = backend`. If you want to know "how do people use the app?", also look at `source = frontend`. + +## 3. The `audit_log` table: a buffer, not a store + +The Penpot `audit_log` table **is not the final destination**. It is a waiting tray: events sit here until the archive cron sends them to Nexus and the cleanup cron (GC) deletes them. + +Main columns (migration `backend/src/app/migrations/sql/0054-add-audit-log-table.sql` and later ones): + +- `id` uuid PK (`gen_random_uuid()` today; the PK used to be `(created_at, profile_id)`). +- `name` / `type` text NOT NULL (for example `name = "create-file"`, `type = "action"`). +- `created_at` timestamptz NOT NULL default `now()`: **server time**, the source of truth. +- `tracked_at` timestamptz default `now()`: what the client claimed; fixed on ingest. +- `profile_id` uuid NOT NULL: who did it. +- `source` text: `backend` / `frontend` for full rows, `telemetry:backend` / `telemetry:frontend` for anonymized copies (see section 10). +- `ip_addr` inet: real IP (or `0.0.0.0` on telemetry rows). +- `props` / `context` jsonb holding transit-encoded maps: action data and metadata (versions, browser, request-id...). +- `archived_at` timestamptz: NULL until Nexus confirms receipt; only then the row can be deleted. + +Indexes (each consumer has its own, so a slow consumer never blocks the others): + +- Partial `created_at WHERE archived_at IS NULL`: used by the archive scan. +- Partial `archived_at WHERE archived_at IS NOT NULL`: used by the GC. +- `(source, created_at)`: used by the telemetry scan. + +Life of a row: `INSERT` → (every 5 min) archive sets `archived_at = now()` **only if Nexus answers 204**, in the same transaction → (every 5 min) GC runs `DELETE WHERE archived_at IS NOT NULL` **with no age filter**. So archive must run before GC, or data is deleted without being sent. + +## 4. Anatomy of an event + +Every event ends up with this shape (schema `schema:event` in `backend/src/app/loggers/audit.clj`): + +- `name`: what happened (`"create-file"`, `"navigate"`, `"create-organization"`...). For backend, it defaults to the RPC command name (prefixed with `-` outside `main`). +- `type`: category (`"action"`, `"identify"`, `"trigger"`). Most are `"action"`. +- `profile-id`: **who** did it (uuid). See the invariant in section 5. +- `ip-addr`: request IP (or `"0.0.0.0"` on telemetry). +- `props`: action data (team/project/file ids, visited route...). **Never secrets** (see `clean-props`). +- `context`: metadata about how/when it was captured (backend/frontend version, user-agent, locale, browser, `request-id`, `initiator`, `event-origin`...). See section 8 for the full field guide, including `initiator` (which app started it: `"app"` means Penpot itself). +- `source`: `backend`, `frontend`, `telemetry:backend`, `telemetry:frontend`. Nitrate admin-console server events land as `backend`; its browser events land as `frontend` (see section 7). +- `source`: `backend`, `frontend`, `telemetry:backend`, `telemetry:frontend`. +- `tracked-at` / `created-at`: claimed time and server time. +- Memory-only (never stored): `::webhooks/event?`, `::webhooks/batch-key`, `::webhooks/batch-timeout` for the webhook fan-out. + +Backend `create-file` example (simplified): + +```clojure +{:name "create-file" :type "action" :source "backend" + :profile-id #uuid "…" :ip-addr "203.0.113.7" + :props {:project-id #uuid "…" :name "My design"} + :context {:version "2.x" :initiator "app" :request-id "…" + :client-version "2.x" :client-user-agent "Mozilla/…"} + :created-at #inst "…" :tracked-at #inst "…"} +``` + +Frontend `navigate` example: + +```clojure +{:name "navigate" :type "action" :source "frontend" + :profile-id #uuid "…" :ip-addr "203.0.113.7" + :props {:route "dashboard" :team-id #uuid "…"} + :context {:version "2.x" :locale "en" :browser "Chrome" :os "Linux" + :event-origin "dashboard" :session-id "…" :session #inst "…"} + :created-at #inst "…" :tracked-at #inst "…"} +``` + +## 5. Backend events: the happy path + +Most backend events need **no manual code**. The `wrap-audit` wrapper in `backend/src/app/rpc.clj` runs after every RPC command when `:webhooks`, `:audit-log`, or `:telemetry` is on (unless the command sets `::audit/skip`). It builds the event with `prepare-rpc-event` and passes it to `audit/submit`. + +Each command can customize through result metadata (`rph/with-meta`): + +- `::audit/replace-props`: replaces props fully (auth commands use `profile->props` so a register event carries the profile, not the password). +- `::audit/props`: merges extras over the request params. +- `::audit/context`, `::audit/profile-id`, `::audit/name`, `::audit/type`: override the defaults. +- `clean-props` always strips nils, namespaced keys, and `:session-id/:password/:old-password/:token/:client-secret` as a last line of defense. **Never put secrets in props.** + +Critical `profile-id` INVARIANT: the event belongs to **whoever made the request**. It resolves as `::audit/profile-id` metadata → `::rpc/profile-id` → `uuid/zero`. It is **never** taken from the response: some results carry someone else's `:profile-id` (for example `get-error-report` returns the report with its content merged; `verify-token` on an invitation returns the inviter), and using it would blame the wrong person. Commands with `::rpc/auth false` (`login-*`, `register-profile`, `create-demo-profile`, `verify-token`, `prepare-register-profile`) have no caller to fall back on, so they **must** set `::audit/profile-id` in the metadata or the event lands on `uuid/zero`. + +The `::audit/profile-id` override goes through `coerce-profile-id`: a string is parsed, anything that cannot become a uuid is dropped (with a warning) and the event falls back to the caller. Do not pass the value raw: `schema:event` needs a uuid and `submit*` swallows the validation error (it only logs, the RPC still succeeds), so a non-uuid override **loses the row silently**. Hand-built events with `event-from-rpc-params` + `submit` (clone template, accept team invitation, `management/nitrate` push-audit-events) do **not** go through that coercion: they must already carry a uuid. + +Entry points: + +- `submit`: the normal one (fills defaults, validates, runs inside `tx-run!`, never breaks the RPC). +- `insert`: low level, 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 stays visible. + +Cases already covered by tests, do not break: `accept-organization-invitation` tells its two flows apart **with props, not with origin**: `:organization-member-add-source` is `"direct-organization-invitation"` or `"team-invitation"`, and `:belongs-to-team-on-add` repeats it as a boolean. Do not add a third prop. The `context` `:event-origin` belongs to the browser (`make-data-event` sets it and it is on the frontend allowlist; `safe-backend-context-keys` has no `:event-origin`, so a backend event has nowhere to put one). + +## 6. Penpot frontend events: what is collected, with what guarantees + +The browser **never writes to the table**; it POSTs transit batches to `push-audit-events` (`backend/src/app/rpc/commands/audit.clj`), which stamps server `id`, session `profile-id`, request IP, server `created-at`, and server-side `:initiator` (from the `x-client` header, see section 8; anything the client sends as `initiator` is overwritten), and distrusts the client clock (future or >1h-late `tracked-at` is reset, the original kept in `context.original-tracked-at`). Without `:audit-log`/`:telemetry`, or on a read-only pool, the endpoint is a no-op. + +Valid types (`schema:frontend-event`): `"action"`, `"identify"`, `"trigger"`. Names must match `#"[\d\w-]{1,50}"` (max 250). `props` is a free map with no schema: **tests are the only guard of the contract** (see section 13). + +What they are for: usage metrics (navigation, editing), workspace file stats, Nitrate membership changes, crash reports (`unhandled-exception` / `exception-page`). **Not proof for audit**: collection is best-effort and includes `PerformanceObserver` noise (`performance-*`, `user-input`, `long-task`). So backend tests must never assert exact frontend event counts. + +How the browser collects (`frontend/src/app/main/data/event.cljs`), most to least important: + +1. **On-demand start**: `initialize` asks the backend with `get-environment-data` (old `get-enabled-flags` is deprecated) and only starts on `:audit-log` or `:telemetry`. On RPC failure it still starts in telemetry mode (the backend will reject if needed). +2. **Potok translation**: `make-proto-event` (events with the `Event` protocol) and `make-data-event` (data-events with `::name`) turn the action into `{type, name, props, context}`. `simplify-props` flattens complex values (maps → `:placeholder/map`, and so on) and drops namespaced keys and nils. Context gains `:event-origin`, `:event-namespace`, `:event-symbol`. +3. **Capped buffer**: queue of max 1024 events (`append-to-buffer` drops beyond that); every 2s debounce (or on logout, or `::force-persist`) a chunk of max 100 is taken, filtered to the current profile, and sent fire-and-forget to `api/main/methods/push-audit-events` as transit. On confirm, the chunk leaves the buffer (`::chunk-persisted`). +4. **Performance noise**: `PerformanceObserver` (`event`/`longtask`) plus blocking measurement with `scheduler.postTask` create `trigger` events (`performance-blocking-event`, `user-input`, `long-task`) with 1s debounce and 1s threshold. Info only, not audit. +5. **`skip-audit?`**: UI flag (for example resuming a pending dashboard action after Nitrate SSO, or moving a team into an organization) so one user gesture is not counted twice. + +## 7. Nitrate admin-console events: the third producer + +The admin-console is the third producer. It sends organization and subscription events through **two channels** that look the same once stored, but have different trust levels. All the sending code (server and browser helpers) lives in the private Nitrate repo: it is not Penpot code and no file paths are cited here. What this section documents is only the Penpot-visible side: endpoints, payloads, and which event names arrive. + +### 7a. Server-side channel (high trust, like backend events) + +Code: the admin-console server has a helper that POSTs to the Penpot **management** API: + +``` +POST api/management/methods/push-audit-events +headers: x-shared-key: "admin-console ", Cookie: +body: {events: [{name, props, profileId, context?}]} +``` + +Auth happens at the HTTP layer (`backend/src/app/http/middleware.clj` `wrap-shared-key-auth`): the `x-shared-key` header carries `" "` (`admin-console` here), checked in constant time against the configured keys; the key-id lands in the event context as `:initiator`. The call goes server-to-server (ky client, internal URI, user cookies forwarded). + +Who uses it: + +- Organization management (best-effort wrapper): `create-organization`, `rename-organization`, `delete-organization`, member add/remove, and more. It optionally passes the UI origin in the context. +- Subscriptions: `create-subscription`, renewals, cancellations, and more, including deployment lookup (saas/selfhost). + +Failure policy: every send is best-effort. A failure never rolls back the business action; it is only recorded for debugging. + +Backend side (`backend/src/app/rpc/management/nitrate.clj` `::push-audit-events`): `::audit/skip`, `::rpc/auth false` (trust comes from the shared key, not from a user session). It builds context with `audit/prepare-context-from-request` + `request-id`, takes the given `profile-id` as-is (no coercion here, must already be a uuid), sets `tracked-at = request-at`, defaults `type` to `"action"`, and calls `audit/submit` (which defaults `source` to `"backend"`). So these rows land as **`source = backend`**: they are audited like backend events because a trusted service set the author. + +### 7b. Browser channel (best-effort, like frontend events) + +Code: the admin-console browser has a helper that POSTs to the Penpot **public** API: + +``` +POST api/main/methods/push-audit-events +credentials: include, keepalive: true +headers: x-client: "penpot-admin-console/" +body: {events: [{name, type: "action", timestamp, props, context: {eventOrigin?}}]} +``` + +Dozens of helpers cover the admin-console UI: `create-organization`, `delete-organization-member`, `create-subscription` / `create-trial-subscription`, `open-subscription-modal`, `start-nitrate-checkout`, `open-current-subscription`, SSO settings flows (`open/apply/success/fail/discard-organization-sso-changes`, activate/deactivate modals), `change-organization-advanced-permission`, `onboarding-step` / `onboarding-finish`, `close-subscription-modal`. These helpers live in the private Nitrate repo: they are not Penpot code, you will not find them in this repository. Only the event names above (what arrives at Penpot) are listed here on purpose. + +These rows land as **`source = frontend`** with the session `profile-id`: same guarantees as Penpot frontend events (best-effort, usage analytics). Every browser helper sends an `eventOrigin` in the context: plain `"admin-console"`, with a suffix only when the same event is emitted from two places in the app (for example the SSO discard event distinguishes the config form from the unsaved-changes modal). + +The Nitrate repo also has a shared, generic push client for the management endpoint (not tied to the admin-console server): any other Nitrate-side service that needs to record something in the Penpot audit log can reuse it instead of writing its own HTTP call. Same endpoint, same payload shape as the server channel above. + +## 8. How the frontend helps the backend audit: HTTP headers + +Besides explicit events, **every RPC request from the frontend carries headers** so the backend can fill its own event `context`. This is how the backend learns "which browser/version/screen" made the request: + +- `x-frontend-version` and `x-client: penpot-frontend/`: added by `default-headers` in `frontend/src/app/util/http.cljs` to **all** fetch requests (unless `omit-default-headers`). The admin-console browser channel sends `penpot-admin-console/` instead (`x-frontend-version` is ignored on the event-ingest path, only `x-client` matters there). +- `user-agent`: set by the browser alone; the backend trims it to 500 chars (`get-client-user-agent`). +- `x-external-session-id`: external SaaS session via `cf/external-session-id`; added by `repo.cljs` (`send!`, special `cmd!`, `multipart-upload`, export...) and also stored by `persist-events` in the frontend event context. The backend checks it (max 256, ignores `"null"`/empty). +- `x-session-id`: `cf/session-id` (tab session), only in the main `send!`. +- `x-event-origin`: UI origin (`::ev/origin` from param metadata, for example `"dashboard"`, `"workspace"`); sent by `send!` and the OIDC/export/upload `cmd!`. The backend trims it to 200 chars and stores it as `:client-event-origin` (different from the `:event-origin` the frontend puts in its own context!). + +On the backend, `prepare-context-from-request` (`backend/src/app/loggers/audit.clj`) joins everything into the event context: `:external-session-id`, `:initiator` (`auth-key-id` or `"app"`), `:access-token-id/type`, `:client-event-origin`, `:client-user-agent`, `:client-version`, `:version` (backend), plus `:request-id`. Only `safe-backend-context-keys` (`:version`, `:initiator`, `:client-version`, `:client-user-agent`) survive in the telemetry shadow copy; the rest (`:client-event-origin`, `:external-session-id`, `:access-token-id/type`, `:request-id`...) is stored fully in the normal row and sent to Nexus, but dropped from telemetry. + +### Context field guide: `initiator` + +`initiator` labels the application that started a request. The server sets it on both channels, but only the shared-key channel authenticates the sending application: + +- **Backend channel** (RPC + management API): it comes from the shared-key auth layer (`wrap-shared-key-auth` in `backend/src/app/http/middleware.clj`): when a trusted service calls the management API with `x-shared-key: " "`, the key-id (lowercased) is stored on the request as `::http/auth-key-id`, and `prepare-context-from-request` copies it into `:initiator`. When there is no shared key (a normal user RPC), it falls back to `"app"`, which means Penpot itself. +- **Frontend channel** (`push-audit-events`): `get-client-initiator` maps the `x-client` header product to an initiator: `penpot-frontend` → `"app"`, `penpot-admin-console` → `"admin-console"`, legacy `penpot-nitrate` (old admin-console releases, remove once redeployed) → `"admin-console"`, missing or unknown → `"app"`. It overwrites any `initiator` in the event context, but the caller controls the header. Any authenticated caller can claim `"admin-console"` by sending that product in `x-client`. Use this label for usage analytics; it does not prove which application sent the event or grant permissions. The session still determines `profile-id`. + +Values you will see: + +- `"app"`: normal Penpot traffic (browser → public RPC or event ingest, or backend internal work). This is the vast majority. +- `"admin-console"`: the authenticated Nitrate admin-console on management API calls (section 7a), or a caller claiming that product on browser events (section 7b, via the `x-client` mapping). +- `"nexus"`, `"exporter"`, `"media-processor"`: other first-party services using their own shared keys (key ids come from `::setup/shared-keys` in `backend/src/app/setup.clj`, derived from the instance secret unless overridden). + +Why it matters: it supports per-application usage counts, with the trust level described above. It survives into telemetry on both channels (one of the four backend keys, and on the frontend allowlist too), so per-initiator counts stay available even on anonymized rows. + +More metadata the Penpot frontend adds inside each event (not as headers): `collect-context` with `ua-parser` (browser, engine, OS, device, screen, CPU arch), `locale` (updated on language change), `:session` / `:session-id` / `:external-session-id`, and SaaS host extras (`add-external-context-info`). + +## 9. Consumers I: webhooks + +Webhooks let an external URL get notified when something happens in a team (for example "a file was created"). The flow has three steps: + +1. **Match.** Some audit events are marked as webhook-relevant when they are created. For each of those, Penpot looks up the active webhooks of the affected team: it reads the team straight from the event, or finds it through the project or the file in the event. Each matching webhook gets its own delivery job. If several quick events belong together, they are grouped into one delivery (the grouping rule travels with the event itself). +2. **Deliver.** Penpot POSTs the event to the webhook URL in the format that webhook asked for: JSON (with camelCase keys), transit, or form-encoded. Every attempt is written to a delivery log with the request, the response, and the error if any, so failures can be inspected later. +3. **Protect.** Each webhook counts its consecutive failures. After 3 in a row, Penpot switches the webhook off automatically, so a dead URL stops receiving traffic until someone fixes it. A successful delivery resets the counter to zero. + +One guarantee: sending webhooks never changes the audit event itself. If delivery fails, only the webhook (and its delivery log) is affected; the audit row stays untouched and still gets archived normally. + +## 10. Consumers II: error reporters + +Both `app.loggers.database` and `app.loggers.mattermost` listen to backend `:error` logs **and** frontend crash events (found by the `::audit/event` marker), through a sliding-buffer channel so a flood cannot stall the app. The database one stores them in `server-error-report` (source 3 = backend log, 4 = audit-log origin, 5 = rate-limit); the Mattermost one forwards a short note to `:error-report-webhook` when set. + +Both reporters only start when the `:error-reporting` flag is on (off by default). The database reporter starts on the flag alone; the Mattermost reporter needs the flag **plus** its webhook URL configured. Without the flag, nothing is stored and `emit` calls from `push-audit-events` are silent no-ops. Each reporter also keeps a runtime on/off switch for emergencies via REPL. + +Result: crash reports only exist if the frontend collector runs and `push-audit-events` accepts `unhandled-exception`/`exception-page`. Turning off the whole pipeline also blinds frontend error reporting. + +## 11. Consumers III: telemetry (shadow rows) + +Telemetry reuses the same table with **anonymized shadow rows** (`source LIKE 'telemetry:%'`): day-truncated times, `0.0.0.0` IPs, props reduced to uuid/boolean/number values plus a few allowed fields (`lang`, `auth-backend`, derived `email-domain` — never raw emails), and a minimal context allowlist. Full + shadow rows can exist together per event when both flags are on; that duplication is on purpose. + +Filters (`filter-telemetry-props` / `filter-telemetry-context`): by default only uuid/boolean/number with a simple key; exceptions: login/register/update-profile and frontend `identify` keep `lang`/`auth-backend`/`email-domain`; `organization-sso-auth-failed` keeps `failure-reason` if it is a known one; `instance-start` passes whole; frontend `navigate` keeps `[:route :file-id :team-id :page-id]`. Free text drops from telemetry but **stays in the full row and in Nexus**. + +Shipping (`backend/src/app/tasks/telemetry.clj`): the cron sends shadows to `:telemetry-uri` as JSON in 10k batches and **deletes** them on success; it purges leftovers older than 7 days. It also sends a legacy report (counters, teams, email domains...). On official hosts nothing is collected or sent (`telemetry-excluded?` covers `penpot.app`/`penpot.dev`). + +### Where telemetry events go: `telemetry.penpot.app` is Nexus + +The default `:telemetry-uri` (`https://telemetry.penpot.app/`) points at the **Nexus telemetry endpoint**, so telemetry is a second road into Nexus, separate from the archive cron. The payload is plain JSON: + +``` +POST https://telemetry.penpot.app/ +{type: "telemetry-events", version, instance-id, events: [...]} +``` + +Nexus dispatches on `:type`: `"telemetry-events"` batches are coerced and inserted into its own `audit_log`; anything else (old flat payloads, `"telemetry-legacy-report"`) is upserted into `telemetry_report` per instance + day. Three details matter: + +- **Anonymization happens in Penpot, before sending.** By the time events leave, times are day-truncated, IPs are already `0.0.0.0`, props/context are filtered (see above), and emails are gone (only derived `email-domain` survives). Nexus additionally forces the outcome: it prefixes any non-`telemetry:` source, sets `received-at = tracked-at` (already truncated), pins IP to `0.0.0.0`, and stamps the sending `instance-id` into every event context. +- **No personal data is expected.** Profile ids are random UUIDs with no PII, so the telemetry consumer needs no login guard; grouping is by `instance`, not by person (see posthog-telemetry below). +- **The endpoint always answers 200.** Failures are swallowed and only counted (`nexus_telemetry_errors_total`), so a bad batch never breaks the sending instance; successes are counted (`nexus_telemetry_events_total`, `nexus_telemetry_reports_total`). Old instances may send a fressian+zstd base64 blob instead of a plain vector; Nexus decodes both (50 MiB cap against decompression bombs). + +## 12. Archive to Nexus (today) and to Nitrate (future) + +### Nexus: how it works today + +Nexus is the long-term store. Every 5 min the `:audit-log-archive` cron takes 128-row chunks of unarchived rows (`FOR UPDATE SKIP LOCKED`, ordered by `created_at`), POSTs them as transit `{:events [...]}` with `x-shared-key: "nexus "` (`:nexus-shared-key`, or derived from the instance secret) and `origin` = public-uri, and marks `archived_at = now()` **only on HTTP 204**, in the same transaction. Anything else is retried next run; flag on but no URI raises `:task-not-configured`. It loops with 100 ms pauses until empty. + +On the other side, Nexus checks `origin` against its allowlist (403 if not allowed), coerces the events, and inserts them into its own `audit_log` (partitioned by `received_at`, with an incrementing sequence number and `source`). Watch the rename: Penpot's `created-at` is stored as **`received-at`** in Nexus; Nexus's `created-at` is when it was created there. `tracked-at` keeps the origin time. Telemetry batches arriving at `telemetry.penpot.app` land in the same table through the telemetry endpoint above. + +Concurrent runs are safe: rows are locked with `FOR UPDATE SKIP LOCKED` inside the same transaction that sends and marks them, so two archivers running at once each take different rows. Only rows Nexus acknowledges (HTTP 204) get marked; anything else is retried on the next run. + +### How Nexus consumes the data (and forwards to PostHog) + +Stored rows are not the end: a consumer pipeline reads them back in sequence order and processes them. Each consumer remembers its place (sequence number), fetches the next chunk, runs its handler, then saves the new position. Three consumers run in order: + +1. **`local`**: keeps a mirror of user profiles. It listens to profile events (`register-profile`, `update-profile`, `login-with-*`, `delete-profile`, ...) and upserts email, name, props, and delete markers. It runs first because the next consumer needs profile data. +2. **`posthog`** (runs after `local`): forwards our own SaaS archived events to PostHog. It sends an allowlist of backend track events (teams, members, projects, files, comments, Nitrate/SSO, ...) plus frontend events minus a noise exclusion list, grouped by **team**, with person identify data from the local profile mirror and snake_case props. Both paths include the `initiator` property when the stored context has one, so PostHog can split Penpot vs admin-console traffic. +3. **`posthog-telemetry`**: forwards events with `source = telemetry:frontend|telemetry:backend` to a separate PostHog project. Because these rows are already anonymous (no IPs, truncated times, filtered props), grouping is by **instance** (the self-hosted server, from `context.instance-id`), with one `$groupidentify` event per instance per chunk so PostHog learns per-instance properties (version, URI, flags). Each consumer has its own PostHog credentials. + +So the full journey is: Penpot buffer → Nexus `audit_log` (via archive cron or telemetry endpoint) → consumers → PostHog (SaaS events grouped by team, telemetry grouped by instance) plus local analytics views and materialized KPIs. + +### Nitrate: what exists and what is planned + +Today **only Nexus archives**. Archiving Penpot → Nitrate is in development and not on this branch. + +What already exists (do not confuse with archiving): + +- **Nitrate → Penpot**: `POST /api/management/methods/push-audit-events` (`backend/src/app/rpc/management/nitrate.clj`) lets the trusted Nitrate backend inject events into the Penpot audit log (no RPC auth, `::audit/skip`, request context + `submit`). Section 7a is the main user. +- **Admin-console browser → collector**: usage events from the admin-console UI traveling through the normal public `push-audit-events` (section 7b). +- **Penpot frontend Nitrate bits**: membership/subscription events (`frontend/src/app/main/data/nitrate.cljs`, `nitrate-audit/*`) through the same public endpoint. + +The future Penpot → Nitrate archive will follow the Nexus pattern (cron reading the buffer and POSTing batches), but there is no contract or flag on this branch yet. When it lands, document here: endpoint, auth, format, which `source` values travel, and how it shares the GC. + +## 13. Flags and config + +Flags (`common/src/app/common/flags.cljc`, varia; enabled with `PENPOT_FLAGS=enable-`): + +| Flag | What it does | +|---|---| +| `:audit-log` | Stores full rows; turns on `wrap-audit` and frontend ingest | +| `:audit-log-archive` | Turns on the cron that sends to Nexus (needs `:audit-log-archive-uri`) | +| `:audit-log-gc` | Turns on the cron that deletes archived rows | +| `:audit-log-logger` | Structured `app.audit` log per event | +| `:error-reporting` | Starts the error reporters (off by default); database reporter on flag alone, Mattermost on flag plus webhook URL | +| `:telemetry` | Stores shadow rows and turns on shipping (auto with `:telemetry-enabled`) | + +Config (`backend/src/app/config.clj`): `:audit-log-archive-uri`, `:telemetry-uri` (default `https://telemetry.penpot.app/`), `:nexus-shared-key` / instance secret, `:public-uri` (sent as `origin` to Nexus). + +## 14. Practical guide: add or change events + +- **New backend command that must (or must not) emit**: `wrap-audit` does it alone; to exclude it set `::audit/skip`. To customize props/name/type use result metadata. Add a test mocking `app.loggers.audit/submit` (nil; `helpers.clj` stubs it globally) and assert `:call-args-list` — see `backend_tests/rpc-audit-test.clj` (full-row, telemetry-only, dual, no-op without flags, `insert`, `prepare-rpc-event`) and `rpc-team-test/accept-organization-invitation-audit-event` (three cases, exact props; counts matter: asserting with `first` passes even when a command emits the same name twice). +- **New Penpot frontend event**: create the data-event or implement `Event`/`PerformanceEvent` in `app.main.data.event`, with `::name`/`::type`/`::origin`. Honor `skip-audit?` if the action can resume. Remember: with no props schema, **your asserts are the contract**. +- **New admin-console event**: server action → add the backend-log call next to the mutation, best-effort so it never breaks the action; browser action → add a new event helper plus its test. +- **Never** put secrets in `props`; `clean-props` is a last defense, not an excuse. +- **Do not duplicate events**: if the backend already emits an event for an action, do not add a frontend (or admin-console browser) event for the same action — the backend one wins because it is reliable. A second event for the same action is only acceptable with a strong reason (for example the backend cannot see it at all), and then it must be clearly justified. +- **Follow the naming policy**: use kebab-case names (`"create-organization"`, not `"createOrganization"`), and before inventing prop keys, look at similar existing events and reuse their key names (`organization-id`, `team-id`, `file-id`, `event-origin`...). Same concept, same key spelling — that is what keeps the data queryable later. + +## 15. Code map + +- Backend producer: `backend/src/app/loggers/audit.clj` (`submit`, `submit*`, `insert`, `prepare-rpc-event`, `event-from-rpc-params`, telemetry filters), `backend/src/app/rpc.clj` (`wrap-audit`), `backend/src/app/rpc/commands/audit.clj` (`push-audit-events`, `get-environment-data`, deprecated `get-enabled-flags`), `backend/src/app/rpc/management/nitrate.clj` (`push-audit-events` Nitrate→Penpot), `backend/src/app/http/middleware.clj` + `backend/src/app/http/management.clj` (shared-key auth). +- Penpot frontend producer: `frontend/src/app/main/data/event.cljs` (collector, buffer, send), `frontend/src/app/main/repo.cljs` + `frontend/src/app/util/http.cljs` (headers), `frontend/src/app/main/data/nitrate.cljs` (Nitrate membership events). +- Admin-console producer (private repo, no paths cited here): server-side organization/subscription events through the management `push-audit-events`, browser-side UI events through the public `push-audit-events`. +- Consumers: `backend/src/app/loggers/webhooks.clj`, `backend/src/app/loggers/database.clj`, `backend/src/app/loggers/mattermost.clj`, `backend/src/app/tasks/telemetry.clj`. +- Archive/GC: `backend/src/app/loggers/audit/archive_task.clj`, `backend/src/app/loggers/audit/gc_task.clj`, wiring and crons in `backend/src/app/main.clj`. +- Nexus (private repo, no paths cited here): archive ingest, ordered consumers (local profile mirror, PostHog SaaS events, PostHog telemetry), telemetry endpoint, analytics views. +- Tests: `backend_tests/rpc-audit-test.clj`, `rpc-team-test/accept-organization-invitation-audit-event`. diff --git a/.serena/memories/backend/audit-log.md b/.serena/memories/backend/audit-log.md index ccd4153655..032a13f45d 100644 --- a/.serena/memories/backend/audit-log.md +++ b/.serena/memories/backend/audit-log.md @@ -1,54 +1,3 @@ # 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. -- INVARIANT: the event's `profile-id` is the caller, resolved as `::audit/profile-id` metadata -> `::rpc/profile-id` -> `uuid/zero`. It is NEVER taken from the response. Results carry `:profile-id` keys that belong to someone else (`get-error-report` returns the report with its content merged, so the profile that owned the report; `verify-token` on a team invitation returns the inviter), and honoring them misattributes the action. Commands with `::rpc/auth false` (`login-*`, `register-profile`, `create-demo-profile`, `verify-token`, `prepare-register-profile`) have no caller to fall back on, so they MUST set `::audit/profile-id` in the result metadata or the event lands on `uuid/zero`. -- The `::audit/profile-id` override goes through `coerce-profile-id`: a string is parsed, anything that cannot become a uuid is discarded (with a warning) and the event falls back to the caller. Do not pass the value straight through: `schema:event` requires `::sm/uuid` and `submit*` swallows the validation error, so a non-uuid override silently loses the row instead of failing loudly. Hand-built events built with `event-from-rpc-params` + `submit` (clone-template, accept-team-invitation, `management/nitrate` push-audit-events) do NOT go through that coercion: they must supply a uuid. -- `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. -- `accept-organization-invitation` distinguishes its two flows with the props, not with an origin: `:organization-member-add-source` is `"direct-organization-invitation"` for a direct organization invitation and `"team-invitation"` for a team invitation that also adds to the organization, and `:belongs-to-team-on-add` repeats the same distinction as a boolean. Do not add a third prop for it. The event `context` has an `:event-origin`, but it belongs to the browser: `make-data-event` in `app.main.data.event` puts the UI origin there and it is on the frontend allowlist, while `safe-backend-context-keys` lists no `:event-origin`, so a backend event has nowhere to put one. Text props are dropped from the telemetry shadow rows by `filter-telemetry-props`; the full `audit_log` row and the Nexus archive still carry them. - -## 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. `rpc-team-test/accept-organization-invitation-audit-event` is the reference for invitation events: three scenarios (direct org invitation, team invitation that adds to the org, already-a-member) asserting the emitted props, the add source and the team flag that tell the two flows apart, and the exact number of rows per event name. The count matters: asserting props against the first match with `first` passes even when a command emits the same name twice. Props have no schema on either ingest path, so these assertions are the only guard on the event contract. +Superseded by `mem:audit-log`: the full audit-log and event-processing guide (backend center, frontend collection, headers, webhooks, error reporters, telemetry, Nexus archive and planned Nitrate archive, table-as-buffer lifecycle, event anatomy, flags, tests). Read that memory instead; keep this stub so old references do not rot. diff --git a/.serena/memories/backend/core.md b/.serena/memories/backend/core.md index 71714d12c3..aa3d0975e5 100644 --- a/.serena/memories/backend/core.md +++ b/.serena/memories/backend/core.md @@ -9,7 +9,7 @@ Backend: JVM Clojure; Integrant; PostgreSQL; Redis/Valkey; RPC; HTTP; storage; m - Embedded Ladybug graph experiment, projection, incremental sync, console, and risks: `mem:backend/graph-experiment` - Session lifetime config, token `:exp`, and idle/absolute GC: `mem:backend/session-expiration`. - 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` +- Audit-log event collection (RPC wrapper, frontend ingestion), telemetry duality, webhook fan-out, error reporters, Nexus archival and retention: `mem:audit-log` (guía completa, también para perfiles junior; el stub `mem:backend/audit-log` solo redirige aquí) - Services, task-queue/Pub-Sub topology constraints -> `mem:prod-infra/core`. ## Stable namespace map diff --git a/backend/src/app/loggers/audit.clj b/backend/src/app/loggers/audit.clj index ab38799865..085d402faf 100644 --- a/backend/src/app/loggers/audit.clj +++ b/backend/src/app/loggers/audit.clj @@ -54,6 +54,7 @@ (def ^:private safe-frontend-context-keys #{:version + :initiator :locale :browser :browser-version @@ -158,6 +159,24 @@ (str/blank? origin)) (str/prune origin 100)))) +(def ^:private client-initiators + {"penpot-frontend" "app" + "penpot-admin-console" "admin-console" + ;; TODO: legacy value sent by older admin-console releases, remove + ;; it once they are all redeployed. + "penpot-nitrate" "admin-console"}) + +(defn get-client-initiator + "Resolve the event initiator from the `x-client` request header. + Never trusts client-sent context: missing or unknown values fall + back to \"app\"." + [request] + (let [product (some-> (yreq/get-header request "x-client") + (str/split #"/" 2) + (first) + (str/lower))] + (get client-initiators product "app"))) + ;; --- SPECS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/src/app/loggers/database.clj b/backend/src/app/loggers/database.clj index cd17dcd51d..be4ad26503 100644 --- a/backend/src/app/loggers/database.clj +++ b/backend/src/app/loggers/database.clj @@ -194,48 +194,50 @@ (defmethod ig/init-key ::reporter [_ cfg] - (let [input (sp/chan :buf (sp/sliding-buffer 256)) - thread (px/thread - {:name "penpot/reporter/database"} - (l/info :hint "initializing database error persistence") - (try - (loop [] - (when-let [item (sp/take! input)] - (cond - (::l/id item) - (handle-log-record cfg item) + (when (contains? cf/flags :error-reporting) + (let [input (sp/chan :buf (sp/sliding-buffer 256)) + thread (px/thread + {:name "penpot/reporter/database"} + (l/info :hint "initializing database error persistence") + (try + (loop [] + (when-let [item (sp/take! input)] + (when @enabled + (cond + (::l/id item) + (handle-log-record cfg item) - (::rlimit/id item) - (handle-rlimit-event cfg item) + (::rlimit/id item) + (handle-rlimit-event cfg item) - (-> item meta ::audit/event) - (handle-audit-event cfg item) + (-> item meta ::audit/event) + (handle-audit-event cfg item) - :else - (l/warn :hint "received unexpected item" :item item)) + :else + (l/warn :hint "received unexpected item" :item item))) - (recur))) + (recur))) - (catch InterruptedException _ - (l/debug :hint "reporter interrupted")) - (catch Throwable cause - (l/error :hint "unexpected error" :cause cause)) - (finally - (l/info :hint "reporter terminated"))))] + (catch InterruptedException _ + (l/debug :hint "reporter interrupted")) + (catch Throwable cause + (l/error :hint "unexpected error" :cause cause)) + (finally + (l/info :hint "reporter terminated"))))] - (add-watch l/log-record ::reporter - (fn [_ _ _ record] - (when (= :error (::l/level record)) - (sp/put! input record)))) + (add-watch l/log-record ::reporter + (fn [_ _ _ record] + (when (= :error (::l/level record)) + (sp/put! input record)))) - {::input input - ::thread thread})) + {::input input + ::thread thread}))) (defmethod ig/halt-key! ::reporter [_ {:keys [::input ::thread]}] (remove-watch l/log-record ::reporter) - (sp/close! input) - (px/interrupt! thread)) + (some-> input sp/close!) + (some-> thread px/interrupt!)) (defn emit "Emit an event/report into the database reporter" diff --git a/backend/src/app/loggers/mattermost.clj b/backend/src/app/loggers/mattermost.clj index 71a61afb37..31820cb6e0 100644 --- a/backend/src/app/loggers/mattermost.clj +++ b/backend/src/app/loggers/mattermost.clj @@ -135,8 +135,10 @@ (defmethod ig/init-key ::reporter [_ cfg] - (when-let [uri (cf/get :error-report-webhook)] - (let [input (sp/chan :buf (sp/sliding-buffer 256)) + (when (and (contains? cf/flags :error-reporting) + (cf/get :error-report-webhook)) + (let [uri (cf/get :error-report-webhook) + input (sp/chan :buf (sp/sliding-buffer 256)) thread (px/thread {:name "penpot/reporter/mattermost"} (l/info :hint "initializing error reporter" :uri uri) diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 9b15b05e27..51e95595c0 100644 --- a/backend/src/app/rpc/commands/audit.clj +++ b/backend/src/app/rpc/commands/audit.clj @@ -75,22 +75,27 @@ (defn- prepare-events [{:keys [::rpc/request-at ::rpc/profile-id events] :as params}] - (let [request (-> params meta ::http/request) - ip-addr (inet/parse-request request) - xform (comp - (map (fn [event] - {:id (uuid/next) - :type (:type event) - :name (:name event) - :props (:props event) - :context (:context event) - :profile-id profile-id - :ip-addr ip-addr - :source "frontend" - :tracked-at (:timestamp event) - :created-at request-at})) - (map (fn [item] - (with-meta item {::audit/event true}))))] + (let [request (-> params meta ::http/request) + ip-addr (inet/parse-request request) + initiator (audit/get-client-initiator request) + xform (comp + (map (fn [event] + {:id (uuid/next) + :type (:type event) + :name (:name event) + :props (:props event) + ;; The initiator always comes from the + ;; server (x-client header); anything the + ;; client sends is overwritten. + :context (assoc (:context event) + :initiator initiator) + :profile-id profile-id + :ip-addr ip-addr + :source "frontend" + :tracked-at (:timestamp event) + :created-at request-at})) + (map (fn [item] + (with-meta item {::audit/event true}))))] (sequence xform events))) diff --git a/backend/test/backend_tests/loggers_error_reporters_test.clj b/backend/test/backend_tests/loggers_error_reporters_test.clj new file mode 100644 index 0000000000..5a6ae47a1e --- /dev/null +++ b/backend/test/backend_tests/loggers_error_reporters_test.clj @@ -0,0 +1,109 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns backend-tests.loggers-error-reporters-test + (:require + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.http.client :as http] + [app.loggers.audit :as audit] + [app.loggers.database :as logdb] + [app.loggers.mattermost :as logmm] + [backend-tests.helpers :as th] + [clojure.test :as t] + [integrant.core :as ig] + [promesa.exec.csp :as sp])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- with-webhook-url + [url f] + (let [real-get @#'cf/get] + (with-redefs [cf/get (fn ([k] (if (= k :error-report-webhook) url (real-get k))) + ([k d] (if (= k :error-report-webhook) url (real-get k d))))] + (f)))) + +(t/deftest database-reporter-does-not-start-without-flag + (with-redefs [cf/flags #{}] + (t/is (nil? (ig/init-key :app.loggers.database/reporter + {::db/pool th/*pool*}))))) + +(t/deftest database-reporter-starts-with-flag + (with-redefs [cf/flags #{:error-reporting}] + (let [reporter (ig/init-key :app.loggers.database/reporter + {::db/pool th/*pool*})] + (try + (t/is (some? (:app.loggers.database/input reporter))) + (t/is (some? (:app.loggers.database/thread reporter))) + (finally + (ig/halt-key! :app.loggers.database/reporter reporter)))))) + +(t/deftest database-reporter-halt-without-start-is-safe + (t/is (nil? (ig/halt-key! :app.loggers.database/reporter nil)))) + +(t/deftest database-reporter-runtime-switch-controls-persistence + (with-redefs [cf/flags #{:error-reporting} + logdb/enabled (atom true)] + (let [input (sp/chan) + reporter (with-redefs [sp/chan (constantly input)] + (ig/init-key :app.loggers.database/reporter + {::db/pool th/*pool*})) + ^Thread thread (::logdb/thread reporter) + event (with-meta {:name "unhandled-exception" + :type "action" + :context {:version "2.19.0"} + :props {:hint "runtime switch test"}} + {::audit/event true})] + (try + (doseq [[label enabled?] [["enabled" true] + ["disabled" false] + ["re-enabled without restarting" true]]] + (t/testing label + (reset! logdb/enabled enabled?) + (let [id (uuid/next)] + (t/is (true? (sp/put! input (assoc event :id id) 5000 ::timeout))) + ;; On an unbuffered channel, the second put completes only + ;; after the reporter finishes processing the first report. + (t/is (true? (sp/put! input (assoc event :id (uuid/next)) 5000 ::timeout))) + (t/is (= (if enabled? [{:id id :source 4}] []) + (th/db-exec! ["select id, source from server_error_report where id = ?" id])))))) + (finally + (sp/close input) + (.join thread 5000) + (ig/halt-key! :app.loggers.database/reporter reporter) + (t/is (not (.isAlive thread)))))))) + +(t/deftest mattermost-reporter-does-not-start-without-flag + (with-redefs [cf/flags #{} + http/client? (constantly true)] + (with-webhook-url "https://example.invalid/hook" + #(t/is (nil? (ig/init-key :app.loggers.mattermost/reporter + {::http/client :dummy})))))) + +(t/deftest mattermost-reporter-does-not-start-without-url + (with-redefs [cf/flags #{:error-reporting} + http/client? (constantly true)] + (with-webhook-url nil + #(t/is (nil? (ig/init-key :app.loggers.mattermost/reporter + {::http/client :dummy})))))) + +(t/deftest mattermost-reporter-starts-with-flag-and-url + (with-redefs [cf/flags #{:error-reporting} + http/client? (constantly true)] + (with-webhook-url "https://example.invalid/hook" + #(let [reporter (ig/init-key :app.loggers.mattermost/reporter + {::http/client :dummy})] + (try + (t/is (some? (:app.loggers.mattermost/input reporter))) + (t/is (some? (:app.loggers.mattermost/thread reporter))) + (finally + (ig/halt-key! :app.loggers.mattermost/reporter reporter))))))) + +(t/deftest emit-without-reporter-is-noop + (t/is (nil? (logdb/emit {} {:type "action" :name "navigate"}))) + (t/is (nil? (logmm/emit {} {:type "action" :name "navigate"})))) diff --git a/backend/test/backend_tests/rpc_audit_test.clj b/backend/test/backend_tests/rpc_audit_test.clj index 6cd08060af..ab823e1d41 100644 --- a/backend/test/backend_tests/rpc_audit_test.clj +++ b/backend/test/backend_tests/rpc_audit_test.clj @@ -524,6 +524,58 @@ (case name "x-frontend-version" " ")))] (t/is (nil? (get-client-version request))))) +(t/deftest get-client-initiator-from-x-client + (let [request (fn [client] + (reify yetti.request/IRequest + (get-header [_ name] + (case name "x-client" client))))] + (t/is (= "app" (audit/get-client-initiator (request nil)))) + (t/is (= "app" (audit/get-client-initiator (request "penpot-frontend/2.19.0")))) + (t/is (= "admin-console" (audit/get-client-initiator (request "penpot-admin-console/2.19.0")))) + (t/is (= "admin-console" (audit/get-client-initiator (request "penpot-nitrate/2.19.0")))) + (t/is (= "admin-console" (audit/get-client-initiator (request "Penpot-Admin-Console/2.19.0")))) + (t/is (= "app" (audit/get-client-initiator (request "something-else/1.0")))))) + +(t/deftest push-events-assigns-initiator-from-x-client + (with-redefs [app.config/flags #{:audit-log}] + (let [prof (th/create-profile* 1 {:is-active true}) + params {::th/type :push-audit-events + ::rpc/profile-id (:id prof) + :events [{:name "navigate" + :props {:route "dashboard"} + :context {:engine "blink" + :initiator "fake-client-value"} + :type "action" + :timestamp (ct/now)}]} + params (with-meta params + {:app.http/request + (th/make-dummy-request + :headers {"x-client" "penpot-admin-console/2.19.0"})}) + out (th/command! params)] + (t/is (nil? (:error out))) + (let [[row] (->> (th/db-exec! ["select * from audit_log"]) + (mapv decode-row))] + ;; The server overwrites whatever initiator the client sends. + (t/is (= "admin-console" (-> row :context :initiator))))))) + +(t/deftest push-events-initiator-defaults-to-app + (with-redefs [app.config/flags #{:audit-log}] + (let [prof (th/create-profile* 1 {:is-active true}) + params {::th/type :push-audit-events + ::rpc/profile-id (:id prof) + :events [{:name "navigate" + :props {:route "dashboard"} + :context {:engine "blink"} + :type "action" + :timestamp (ct/now)}]} + params (with-meta params + {:app.http/request (th/make-dummy-request)}) + out (th/command! params)] + (t/is (nil? (:error out))) + (let [[row] (->> (th/db-exec! ["select * from audit_log"]) + (mapv decode-row))] + (t/is (= "app" (-> row :context :initiator))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INSERT DEFAULTS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/backend/test/backend_tests/tasks_telemetry_test.clj b/backend/test/backend_tests/tasks_telemetry_test.clj index c243fb1cb3..79f29f335b 100644 --- a/backend/test/backend_tests/tasks_telemetry_test.clj +++ b/backend/test/backend_tests/tasks_telemetry_test.clj @@ -623,6 +623,8 @@ (t/deftest test-filter-telemetry-context-strips-pii-keys ;; Session-linking and access-token fields must be removed. + ;; The initiator survives: it only says which app sent the event + ;; ("app", "admin-console") and carries no personal data. (let [filter-telemetry-context (ns-resolve 'app.loggers.audit 'filter-telemetry-context) ctx {:browser "Firefox" :session "abc-session-id" @@ -633,10 +635,10 @@ :access-token-type "api-key"} result (:context (filter-telemetry-context {:source "frontend" :context ctx}))] (t/is (= "Firefox" (:browser result))) + (t/is (= "app" (:initiator result))) (t/is (not (contains? result :session))) (t/is (not (contains? result :external-session-id))) (t/is (not (contains? result :file-stats))) - (t/is (not (contains? result :initiator))) (t/is (not (contains? result :access-token-id))) (t/is (not (contains? result :access-token-type))))) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 60353f730f..a3f3cf44be 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -84,6 +84,10 @@ :demo-users ;; disabled by default. When enabled, it displays a warning that this is a test instance and data will be deleted periodically. :demo-warning + ;; disabled by default. When enabled, server errors are persisted + ;; on the database (server-error-report) and notified to mattermost + ;; (only when the webhook url is also configured). + :error-reporting ;; Activates the schema validation during update file. :file-schema-validation ;; Reports the schema validation errors internally. diff --git a/package.json b/package.json index f3c7a83ac1..1289dce20b 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,14 @@ }, "type": "module", "devDependencies": { - "@playwright/mcp": "^0.0.78", - "@playwright/test": "^1.62.0", - "@types/node": "^26.1.2", + "@playwright/mcp": "^0.0.83", + "@playwright/test": "^1.63.0", + "@types/node": "^26.6.3", "commander": "^15.0.0", - "dotenv": "^17.4.2", - "esbuild": "^0.28.1", - "mdts": "^0.20.6", + "dotenv": "^18.0.4", + "esbuild": "^0.28.2", + "mdts": "^0.21.0", "nrepl-client": "^0.3.0", - "playwright": "1.62.1" + "playwright": "^1.63.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3be0d85482..7e2bdb7d80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,195 +162,192 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -overrides: - playwright@>=1.61.1 <2.0.0-0: 1.62.1 - importers: .: devDependencies: '@playwright/mcp': - specifier: ^0.0.78 - version: 0.0.78 + specifier: ^0.0.83 + version: 0.0.83 '@playwright/test': - specifier: ^1.62.0 - version: 1.62.0 + specifier: ^1.63.0 + version: 1.63.0 '@types/node': - specifier: ^26.1.2 - version: 26.1.2 + specifier: ^26.6.3 + version: 26.6.3 commander: specifier: ^15.0.0 version: 15.0.0 dotenv: - specifier: ^17.4.2 - version: 17.4.2 + specifier: ^18.0.4 + version: 18.0.4 esbuild: - specifier: ^0.28.1 - version: 0.28.1 + specifier: ^0.28.2 + version: 0.28.2 mdts: - specifier: ^0.20.6 - version: 0.20.6 + specifier: ^0.21.0 + version: 0.21.0 nrepl-client: specifier: ^0.3.0 version: 0.3.0 playwright: - specifier: 1.62.1 - version: 1.62.1 + specifier: ^1.63.0 + version: 1.63.0 packages: - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -361,31 +358,31 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} - '@playwright/mcp@0.0.78': - resolution: {integrity: sha512-XLTUeA6mEN9sQ+hJ4dfG8EIkDbxS0K3Trc2RBkUJuf02TgE2FQRNTMtq/aJfhyRMINsRl/Ybc4sxcWLtFn4/TQ==} + '@playwright/mcp@0.0.83': + resolution: {integrity: sha512-oNcl+Ae2/IAjhfPeP46BfIkSakfmprY+aOtkv5MjrQ4lPav4/yNtPhL0iq8SlIM90oApWgBDUxaNKvktazUKOg==} engines: {node: '>=18'} hasBin: true - '@playwright/test@1.62.0': - resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} engines: {node: '>=20'} hasBin: true - '@simple-git/args-pathspec@1.0.3': - resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==} + '@simple-git/args-pathspec@1.0.4': + resolution: {integrity: sha512-EtMX6XjRWSastG2SdkmSQByPtJ9dx/NIjnHbpQPCYt62j4RmSx5rgLTGpw0YCjF5h191gZOmBfheOT23cRSFdw==} '@simple-git/argv-parser@1.1.1': resolution: {integrity: sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==} - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@26.6.3': + resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@3.0.2: + resolution: {integrity: sha512-mFdDM6WqWKraGLsVb+C9CahPnzTXOefAOLq3jYcca2YZ8bEWpr++Tzj+zSaKW9+X9L5uSxcm1AZ3Y6aZJ09OhQ==} balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} @@ -398,12 +395,12 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} - bundle-name@4.1.0: - resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + bundle-name@4.1.1: + resolution: {integrity: sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==} engines: {node: '>=18'} bytes@3.1.2: @@ -418,9 +415,9 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chalk@6.0.1: + resolution: {integrity: sha512-/Ce6KNm3vIbWdMlNna6RVIZ/ICQxnJxCicet5LBKK9ZffBkqzDw0xh9EiKSljdRtiIQ1S1z4YgcscUUGzNCWrA==} + engines: {node: '>=22'} chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} @@ -445,8 +442,8 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - content-type@2.0.0: - resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} cookie-signature@1.2.2: @@ -474,8 +471,8 @@ packages: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - default-browser@5.5.0: - resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} engines: {node: '>=18'} define-lazy-prop@3.0.0: @@ -486,9 +483,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + dotenv@18.0.4: + resolution: {integrity: sha512-jGt9uI5BIxpVO15kvy+iEFqNAwxYClKWnkptLjSNPnFLC003Imd0ofr3aEVsbpHdSBdSsbcApNIlz4nbyGMwdA==} engines: {node: '>=12'} + hasBin: true dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} @@ -504,9 +502,9 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} @@ -520,8 +518,8 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -552,11 +550,6 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -638,23 +631,23 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - linkify-it@5.0.2: - resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + linkify-it@6.1.0: + resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==} - lru-cache@11.5.2: - resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} - markdown-it@14.3.0: - resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + markdown-it@15.0.2: + resolution: {integrity: sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==} hasBin: true math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdts@0.20.6: - resolution: {integrity: sha512-neAeLeOwAalXG5NhaUZ7iLj3R0N7lsCCsGQ5utDwSJaMNZSspeEyjjYCdoQHx7b10958S8cmEYKSpQR6DamCyg==} + mdts@0.21.0: + resolution: {integrity: sha512-BVwfGUr31+HBuZFXjOhFvUgiz6TZWKKEhuEenb7dVdGfhvceHi38ps6DiBWxH2Uv3jUfubXFh9ZDH7/r25w+BA==} hasBin: true mdurl@2.1.0: @@ -694,9 +687,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} node-nailgun-client@0.1.2: resolution: {integrity: sha512-OC611lR0fsDUSptwnhBf8d3sj4DZ5fiRKfS2QaGPe0kR3Dt9YoZr1MY7utK0scFPTbXuQdSBBbeoKYVbME1q5g==} @@ -733,8 +726,8 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - open@11.0.0: - resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + open@11.0.4: + resolution: {integrity: sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==} engines: {node: '>=20'} parseurl@1.3.3: @@ -758,23 +751,23 @@ packages: plantuml@0.0.2: resolution: {integrity: sha512-3YzQJUO1Yg+mDckTm3Ht5Q8bmtN8g3M9LD8fXqiqHDW3vzUpHrUe9lxVY6AT1I50w7FdOned0hhJno4JBIku2g==} - playwright-core@1.62.0-alpha-1783623505000: - resolution: {integrity: sha512-CPJZdsA/KGT2QQlekiV6Wt+QlQrZHVSZ6oiNtOI/bYYOIVLM8jfKGWTM4zQiyd4UN+40Cq4cA6lxmZHZbtPvJQ==} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} engines: {node: '>=20'} hasBin: true - playwright-core@1.62.1: - resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + playwright-core@1.64.0-alpha-1790635538000: + resolution: {integrity: sha512-pNwaXirhXMRLaRQs4NQ18EpTdtDoFwyPH9FOaaDC7YXG4hpwE2xmwCic/1srbuExVQC7zT9LJSJpDBAru+Vo9A==} engines: {node: '>=20'} hasBin: true - playwright@1.62.0-alpha-1783623505000: - resolution: {integrity: sha512-6KV9h4PP3hqu4NaGdxxcijWfYh9LJcFI/R2sP4TTC4I5cFo3oRawN0ETlW5MkE3cQEgKhhoj0KUNz4sfpCT0Tg==} + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} engines: {node: '>=20'} hasBin: true - playwright@1.62.1: - resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + playwright@1.64.0-alpha-1790635538000: + resolution: {integrity: sha512-/5XDUMxpOd/9AojJtWDFIRV3EJZNQ0AwvK69wgt3CPzy8wTgiBG9pX45VfxtqtvIRGwfWzMhrvawNKreG69Snw==} engines: {node: '>=20'} hasBin: true @@ -782,8 +775,12 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + powershell-utils@0.2.1: + resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} + engines: {node: '>=20'} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} engines: {node: '>= 0.10'} pump@3.0.4: @@ -793,8 +790,8 @@ packages: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} range-parser@1.3.0: @@ -881,11 +878,11 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - uc.micro@2.1.0: - resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + uc.micro@3.0.0: + resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==} - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -903,8 +900,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -915,88 +912,88 @@ packages: utf-8-validate: optional: true - wsl-utils@0.3.1: - resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + wsl-utils@1.0.0: + resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} engines: {node: '>=20'} snapshots: - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true '@kwsites/file-exists@1.1.1': @@ -1007,31 +1004,31 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} - '@playwright/mcp@0.0.78': + '@playwright/mcp@0.0.83': dependencies: - playwright: 1.62.0-alpha-1783623505000 - playwright-core: 1.62.0-alpha-1783623505000 + playwright: 1.64.0-alpha-1790635538000 + playwright-core: 1.64.0-alpha-1790635538000 - '@playwright/test@1.62.0': + '@playwright/test@1.63.0': dependencies: - playwright: 1.62.1 + playwright: 1.63.0 - '@simple-git/args-pathspec@1.0.3': {} + '@simple-git/args-pathspec@1.0.4': {} '@simple-git/argv-parser@1.1.1': dependencies: - '@simple-git/args-pathspec': 1.0.3 + '@simple-git/args-pathspec': 1.0.4 - '@types/node@26.1.2': + '@types/node@26.6.3': dependencies: - undici-types: 8.3.0 + undici-types: 8.9.0 accepts@2.0.0: dependencies: mime-types: 3.0.2 - negotiator: 1.0.0 + negotiator: 1.1.0 - argparse@2.0.1: {} + argparse@3.0.2: {} balanced-match@4.0.4: {} @@ -1040,22 +1037,22 @@ snapshots: body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 2.0.0 + content-type: 2.1.0 debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: - supports-color - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 - bundle-name@4.1.0: + bundle-name@4.1.1: dependencies: run-applescript: 7.1.0 @@ -1071,7 +1068,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - chalk@5.6.2: {} + chalk@6.0.1: {} chokidar@4.0.3: dependencies: @@ -1087,7 +1084,7 @@ snapshots: content-type@1.0.5: {} - content-type@2.0.0: {} + content-type@2.1.0: {} cookie-signature@1.2.2: {} @@ -1105,16 +1102,16 @@ snapshots: default-browser-id@5.0.1: {} - default-browser@5.5.0: + default-browser@5.5.1: dependencies: - bundle-name: 4.1.0 + bundle-name: 4.1.1 default-browser-id: 5.0.1 define-lazy-prop@3.0.0: {} depd@2.0.0: {} - dotenv@17.4.2: {} + dotenv@18.0.4: {} dunder-proto@1.0.1: dependencies: @@ -1130,7 +1127,7 @@ snapshots: dependencies: once: 1.4.0 - entities@4.5.0: {} + entities@8.1.0: {} es-define-property@1.0.1: {} @@ -1140,34 +1137,34 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escape-html@1.0.3: {} @@ -1206,8 +1203,8 @@ snapshots: on-finished: 2.4.1 once: 1.4.0 parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.15.3 + proxy-addr: 2.0.8 + qs: 6.16.0 range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 @@ -1233,9 +1230,6 @@ snapshots: fresh@2.0.0: {} - fsevents@2.3.2: - optional: true - function-bind@1.1.2: {} get-intrinsic@1.3.0: @@ -1312,38 +1306,38 @@ snapshots: isexe@2.0.0: {} - linkify-it@5.0.2: + linkify-it@6.1.0: dependencies: - uc.micro: 2.1.0 + uc.micro: 3.0.0 - lru-cache@11.5.2: {} + lru-cache@11.5.3: {} - markdown-it@14.3.0: + markdown-it@15.0.2: dependencies: - argparse: 2.0.1 - entities: 4.5.0 - linkify-it: 5.0.2 + argparse: 3.0.2 + entities: 8.1.0 + linkify-it: 6.1.0 mdurl: 2.1.0 punycode.js: 2.3.1 - uc.micro: 2.1.0 + uc.micro: 3.0.0 math-intrinsics@1.1.0: {} - mdts@0.20.6: + mdts@0.21.0: dependencies: - chalk: 5.6.2 + chalk: 6.0.1 chokidar: 4.0.3 commander: 15.0.0 express: 5.2.1 github-slugger: 2.0.0 glob: 13.0.6 - markdown-it: 14.3.0 + markdown-it: 15.0.2 node-plantuml-back: 1.1.4 - open: 11.0.0 + open: 11.0.4 plantuml: 0.0.2 plantuml-encoder: 1.4.0 simple-git: 3.36.0 - ws: 8.21.1 + ws: 8.22.0 transitivePeerDependencies: - bufferutil - supports-color @@ -1367,13 +1361,15 @@ snapshots: minimatch@10.2.6: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 minipass@7.1.3: {} ms@2.1.3: {} - negotiator@1.0.0: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 node-nailgun-client@0.1.2: dependencies: @@ -1411,14 +1407,14 @@ snapshots: dependencies: mimic-fn: 2.1.0 - open@11.0.0: + open@11.0.4: dependencies: - default-browser: 5.5.0 + default-browser: 5.5.1 define-lazy-prop: 3.0.0 is-in-ssh: 1.0.0 is-inside-container: 1.0.0 - powershell-utils: 0.1.0 - wsl-utils: 0.3.1 + powershell-utils: 0.2.1 + wsl-utils: 1.0.0 parseurl@1.3.3: {} @@ -1426,7 +1422,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.5.2 + lru-cache: 11.5.3 minipass: 7.1.3 path-to-regexp@8.4.2: {} @@ -1438,25 +1434,23 @@ snapshots: execa: 4.1.0 get-stream: 5.2.0 - playwright-core@1.62.0-alpha-1783623505000: {} + playwright-core@1.63.0: {} - playwright-core@1.62.1: {} + playwright-core@1.64.0-alpha-1790635538000: {} - playwright@1.62.0-alpha-1783623505000: + playwright@1.63.0: dependencies: - playwright-core: 1.62.0-alpha-1783623505000 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.63.0 - playwright@1.62.1: + playwright@1.64.0-alpha-1790635538000: dependencies: - playwright-core: 1.62.1 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.64.0-alpha-1790635538000 powershell-utils@0.1.0: {} - proxy-addr@2.0.7: + powershell-utils@0.2.1: {} + + proxy-addr@2.0.8: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 @@ -1468,7 +1462,7 @@ snapshots: punycode.js@2.3.1: {} - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -1565,7 +1559,7 @@ snapshots: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 - '@simple-git/args-pathspec': 1.0.3 + '@simple-git/args-pathspec': 1.0.4 '@simple-git/argv-parser': 1.1.1 debug: 4.4.3 transitivePeerDependencies: @@ -1581,13 +1575,13 @@ snapshots: type-is@2.1.0: dependencies: - content-type: 2.0.0 + content-type: 2.1.0 media-typer: 1.1.1 mime-types: 3.0.2 - uc.micro@2.1.0: {} + uc.micro@3.0.0: {} - undici-types@8.3.0: {} + undici-types@8.9.0: {} unpipe@1.0.0: {} @@ -1599,9 +1593,9 @@ snapshots: wrappy@1.0.2: {} - ws@8.21.1: {} + ws@8.22.0: {} - wsl-utils@0.3.1: + wsl-utils@1.0.0: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 612f46bca4..1fdfc3dcee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,3 @@ minimumReleaseAgeExclude: - playwright-core@1.62.1 - playwright@1.62.1 - brace-expansion@5.0.9 - -overrides: - "playwright@>=1.61.1 <2.0.0-0": "1.62.1" diff --git a/scripts/start-mdts-server b/scripts/start-mdts-server index e990059a48..9256d58a22 100755 --- a/scripts/start-mdts-server +++ b/scripts/start-mdts-server @@ -1,4 +1,4 @@ #!/usr/bin/env bash set -e; -pnpm exec mdts --host 0.0.0.0 --port 14179 +pnpm exec mdts --host 0.0.0.0 --port 14179 "$@" From 7e73a4212377f792f4f401e0e701c018c4d5ff70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 2 Oct 2026 09:12:29 +0000 Subject: [PATCH 08/14] :construction_worker: Label Dependabot PRs and skip them in auto-label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependabot PRs get the dependencies, team infra and no issue required labels, and auto-label no longer adds them to the triage board. AI-assisted-by: Claude Signed-off-by: David Barragán Merino --- .github/dependabot.yml | 4 ++++ .github/workflows/auto-label.yml | 2 ++ 2 files changed, 6 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f0c1a8ddff..80e71fedab 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,6 +8,10 @@ updates: schedule: interval: "monthly" open-pull-requests-limit: 5 + labels: + - "dependencies" + - "team infra" + - "no issue required" commit-message: prefix: ":arrow_up:" groups: diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index 7d50d52a88..0b6838f7c8 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -13,6 +13,8 @@ permissions: {} jobs: triage: + # Dependabot PRs are labelled by dependabot.yml and do not need triage. + if: github.actor != 'dependabot[bot]' runs-on: ubuntu-latest steps: - name: Generate GitHub App token From 63ee26a502d54525eec2636f21e4e4278cd06140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 2 Oct 2026 09:12:30 +0000 Subject: [PATCH 09/14] :construction_worker: Require ci-maintainers review for .github MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a CODEOWNERS entry for .github/, so changes to workflows, CI configuration and repository automation need a review from the ci-maintainers team. AI-assisted-by: Claude Signed-off-by: David Barragán Merino --- .github/CODEOWNERS | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..2f3c88cb33 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# Changes to workflows, CI configuration and repository automation need +# review from the ci-maintainers team. Issue and PR templates do not. +/.github/workflows/ @penpot/ci-maintainers +/.github/actions/ @penpot/ci-maintainers +/.github/scripts/ @penpot/ci-maintainers +/.github/dependabot.yml @penpot/ci-maintainers +/.github/zizmor.yml @penpot/ci-maintainers +/.github/actionlint.yaml @penpot/ci-maintainers +/.github/CODEOWNERS @penpot/ci-maintainers From e65d10017ddfdc086dd05bb4c3e804d0dfa338c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 2 Oct 2026 09:12:31 +0000 Subject: [PATCH 10/14] :construction_worker: Use a slug of the ref for Docker tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A git ref accepts characters that a Docker tag does not, such as "/" in feature/foo, which made the promote and retag steps fail. Tag images with a slug of the ref instead (anything outside [A-Za-z0-9._-] becomes "-", at most 100 chars), as payments already does. develop, staging and release tags are unchanged. AI-assisted-by: Claude Signed-off-by: David Barragán Merino --- .github/workflows/build-docker.yml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml index a590606585..a794565d25 100644 --- a/.github/workflows/build-docker.yml +++ b/.github/workflows/build-docker.yml @@ -81,6 +81,7 @@ jobs: timeout-minutes: 15 outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} + ref_slug: ${{ steps.vars.outputs.ref_slug }} bundle_version: ${{ steps.vars.outputs.bundle_version }} sha: ${{ steps.vars.outputs.sha }} short_sha: ${{ steps.vars.outputs.short_sha }} @@ -100,7 +101,10 @@ jobs: env: GH_REF: ${{ inputs.gh_ref || github.ref_name }} run: | + # A git ref accepts characters that a Docker tag does not. + REF_SLUG=$(printf '%s' "$GH_REF" | tr -c 'A-Za-z0-9._-' '-' | cut -c1-100) echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT + echo "ref_slug=$REF_SLUG" >> $GITHUB_OUTPUT echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "short_sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT @@ -300,13 +304,13 @@ jobs: - name: Point branch tags to the new build key env: DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} - GH_REF: ${{ needs.prepare.outputs.gh_ref }} + REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} run: | set -e for image in $ALL_IMAGES; do docker buildx imagetools create \ - -t "${DOCKER_REGISTRY}/${image}:${GH_REF}" \ + -t "${DOCKER_REGISTRY}/${image}:${REF_SLUG}" \ "${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}" done @@ -325,7 +329,7 @@ jobs: - name: Write step summary env: - GH_REF: ${{ needs.prepare.outputs.gh_ref }} + REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }} COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }} @@ -337,7 +341,7 @@ jobs: echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}" echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" echo "" - echo "All \`:${GH_REF}\` tags now point to \`sha-${SHORT_SHA}\`." + echo "All \`:${REF_SLUG}\` tags now point to \`sha-${SHORT_SHA}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 3b. Skip path: make sure THIS ref's tags point to the existing @@ -369,19 +373,19 @@ jobs: - name: Point branch tags to the existing build key env: DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }} - GH_REF: ${{ needs.prepare.outputs.gh_ref }} + REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} run: | set -e for image in $ALL_IMAGES; do docker buildx imagetools create \ - -t "${DOCKER_REGISTRY}/${image}:${GH_REF}" \ + -t "${DOCKER_REGISTRY}/${image}:${REF_SLUG}" \ "${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}" done - name: Write step summary env: - GH_REF: ${{ needs.prepare.outputs.gh_ref }} + REF_SLUG: ${{ needs.prepare.outputs.ref_slug }} SHORT_SHA: ${{ needs.prepare.outputs.short_sha }} BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }} COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }} @@ -393,7 +397,7 @@ jobs: echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}" echo "- Checked at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" echo "" - echo "All \`:${GH_REF}\` tags now point to \`sha-${SHORT_SHA}\`." + echo "All \`:${REF_SLUG}\` tags now point to \`sha-${SHORT_SHA}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 4. Single failure notification for the whole workflow ───────────── From 61bc5853864ab2470c3ad7227e8dcc43cac1ba09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Fri, 2 Oct 2026 10:47:05 +0000 Subject: [PATCH 11/14] :construction_worker: Accept remaining zizmor exceptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wrangler stays an ad hoc install in the plugin deploys and the devenv image keeps its floating tag: both are deliberate, so mark them as accepted instead of pending. AI-assisted-by: Claude Signed-off-by: David Barragán Merino --- .github/zizmor.yml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/zizmor.yml b/.github/zizmor.yml index f86f018b5d..8f6e7f112d 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -6,9 +6,9 @@ # Remove a file from a list in the same PR that fixes it, and the whole rule # entry once its list is empty. rules: - # Pending: wrangler is added with `pnpm add wrangler@latest` before each - # deploy because it is not a dependency of the plugins workspace. Fixed by - # adding it to plugins/package.json (devDependencies) and dropping that line. + # Accepted: wrangler is added with `pnpm add wrangler@latest` before each + # deploy instead of being a dependency of the plugins workspace. Only used + # to publish to Cloudflare; not worth adding to the workspace. adhoc-packages: ignore: - plugins-deploy-api-doc.yml @@ -23,9 +23,8 @@ rules: # Style nudge towards the `$/...` syntax; not worth enforcing. self-repository: disable: true - # Accepted for now: penpotapp/devenv is our own image, built and pushed by - # build-docker-devenv.yml. Pinning it by digest means bumping ten files on - # every rebuild; revisit once Renovate can do that automatically. + # Accepted: penpotapp/devenv is our own image, built and pushed by + # build-docker-devenv.yml, so the floating tag is intentional. unpinned-images: ignore: - plugins-deploy-package.yml From c04a3d346d7b5f0015d600050ca9c4cd511c5292 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 5 Oct 2026 08:25:15 +0200 Subject: [PATCH 12/14] :wrench: Register the missing test namespaces in the common JS runner (#12074) Kaocha finds every test namespace on the JVM, but the JS runner only runs the ones listed in runner.cljc. Sixteen namespaces were missing from both lists, and files.shapes-builder-test was required but not listed, so 125 tests never ran in JS. Add them to both lists, sorted. The only one that failed in JS was make-tokens-status-from-tokens-lib: (.sets base-lib) is a method call in ClojureScript. On the JVM it read the field, but (:sets ...) on the result gave nil, so the legacy lib was built without sets or themes. Use (.-sets base-lib) and (.-themes base-lib), as the first part of the same test does. AI-assisted-by: claude-opus-5-5 --- common/test/common_tests/runner.cljc | 41 +++++++++++++++++-- .../types/tokens_migrations_test.cljc | 4 +- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 728fafda6d..081516c73c 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -21,8 +21,11 @@ [common-tests.files-migrations-0025-test] [common-tests.files-migrations-0026-test] [common-tests.files-migrations-test] + [common-tests.files.comp-processors-test] [common-tests.files.helpers-test] + [common-tests.files.repair-test] [common-tests.files.shapes-builder-test] + [common-tests.files.tokens-test] [common-tests.files.validate-test] [common-tests.geom-align-test] [common-tests.geom-bounds-layout-nil-test] @@ -48,6 +51,7 @@ [common-tests.geom-shapes-tree-seq-test] [common-tests.geom-snap-test] [common-tests.geom-test] + [common-tests.helpers-test] [common-tests.logging-test] [common-tests.logic.chained-propagation-test] [common-tests.logic.comp-creation-test] @@ -63,8 +67,13 @@ [common-tests.logic.multiple-nesting-levels-test] [common-tests.logic.swap-and-reset-test] [common-tests.logic.swap-as-override-test] + [common-tests.logic.swap-keeps-id-test] + [common-tests.logic.text-sync-test] + [common-tests.logic.text-touched-test] + [common-tests.logic.token-apply-test] [common-tests.logic.token-test] [common-tests.logic.variants-switch-test] + [common-tests.logic.variants-test] [common-tests.math-test] [common-tests.media-test] [common-tests.path-names-test] @@ -75,11 +84,14 @@ [common-tests.svg-test] [common-tests.text-test] [common-tests.time-test] + [common-tests.token-test] + [common-tests.types-shape-tree-test] [common-tests.types.absorb-assets-test] [common-tests.types.color-test] [common-tests.types.components-test] [common-tests.types.container-test] [common-tests.types.fill-test] + [common-tests.types.font-test] [common-tests.types.modifiers-test] [common-tests.types.objects-map-test] [common-tests.types.organization-test] @@ -89,11 +101,15 @@ [common-tests.types.shape-interactions-test] [common-tests.types.shape-layout-test] [common-tests.types.stroke-test] + [common-tests.types.text-test] [common-tests.types.token-test] [common-tests.types.tokens-lib-test] + [common-tests.types.tokens-migrations-test] [common-tests.types.tokens-status-test] + [common-tests.types.variant-test] [common-tests.undo-stack-test] - [common-tests.uuid-test])) + [common-tests.uuid-test] + [common-tests.variant-test])) (def test-namespaces [#?(:clj 'common-tests.fressian-test) @@ -101,12 +117,16 @@ 'common-tests.buffer-test 'common-tests.colors-test 'common-tests.data-test - 'common-tests.files-changes-test 'common-tests.files-builder-test - 'common-tests.files.helpers-test + 'common-tests.files-changes-test 'common-tests.files-migrations-0025-test 'common-tests.files-migrations-0026-test 'common-tests.files-migrations-test + 'common-tests.files.comp-processors-test + 'common-tests.files.helpers-test + 'common-tests.files.repair-test + 'common-tests.files.shapes-builder-test + 'common-tests.files.tokens-test 'common-tests.files.validate-test 'common-tests.geom-align-test 'common-tests.geom-bounds-layout-nil-test @@ -132,6 +152,7 @@ 'common-tests.geom-shapes-tree-seq-test 'common-tests.geom-snap-test 'common-tests.geom-test + 'common-tests.helpers-test 'common-tests.logging-test 'common-tests.logic.chained-propagation-test 'common-tests.logic.comp-creation-test @@ -147,8 +168,13 @@ 'common-tests.logic.multiple-nesting-levels-test 'common-tests.logic.swap-and-reset-test 'common-tests.logic.swap-as-override-test + 'common-tests.logic.swap-keeps-id-test + 'common-tests.logic.text-sync-test + 'common-tests.logic.text-touched-test + 'common-tests.logic.token-apply-test 'common-tests.logic.token-test 'common-tests.logic.variants-switch-test + 'common-tests.logic.variants-test 'common-tests.math-test 'common-tests.media-test 'common-tests.path-names-test @@ -159,11 +185,14 @@ 'common-tests.svg-test 'common-tests.text-test 'common-tests.time-test + 'common-tests.token-test + 'common-tests.types-shape-tree-test 'common-tests.types.absorb-assets-test 'common-tests.types.color-test 'common-tests.types.components-test 'common-tests.types.container-test 'common-tests.types.fill-test + 'common-tests.types.font-test 'common-tests.types.modifiers-test 'common-tests.types.objects-map-test 'common-tests.types.organization-test @@ -173,11 +202,15 @@ 'common-tests.types.shape-interactions-test 'common-tests.types.shape-layout-test 'common-tests.types.stroke-test + 'common-tests.types.text-test 'common-tests.types.token-test 'common-tests.types.tokens-lib-test + 'common-tests.types.tokens-migrations-test 'common-tests.types.tokens-status-test + 'common-tests.types.variant-test 'common-tests.undo-stack-test - 'common-tests.uuid-test]) + 'common-tests.uuid-test + 'common-tests.variant-test]) #?(:cljs (assert (every? find-ns-obj test-namespaces) diff --git a/common/test/common_tests/types/tokens_migrations_test.cljc b/common/test/common_tests/types/tokens_migrations_test.cljc index 31b8441e92..880d0c9432 100644 --- a/common/test/common_tests/types/tokens_migrations_test.cljc +++ b/common/test/common_tests/types/tokens_migrations_test.cljc @@ -126,8 +126,8 @@ (t/testing "returns empty sets when no active themes" (let [base-lib (ctob/make-tokens-lib) tokens-lib (ctob/map->tokens-lib - {:sets (:sets (.sets base-lib)) - :themes (:themes (.themes base-lib)) + {:sets (.-sets base-lib) + :themes (.-themes base-lib) :active-themes #{}}) status (cfo/make-tokens-status-from-lib tokens-lib)] (t/is (= #{} (ctos/get-active-theme-ids status))) From 4c507e56e3c8a488ef28a22afd643c9ac404ac3f Mon Sep 17 00:00:00 2001 From: "Dr. Dominik Jain" Date: Mon, 5 Oct 2026 11:22:32 +0200 Subject: [PATCH 13/14] :books: Update MCP API type information (#12078) --- mcp/packages/server/data/api_types.yml | 434 ++++++++++++++++++++++--- 1 file changed, 393 insertions(+), 41 deletions(-) diff --git a/mcp/packages/server/data/api_types.yml b/mcp/packages/server/data/api_types.yml index 0b53f1dfc0..e0ebcde698 100644 --- a/mcp/packages/server/data/api_types.yml +++ b/mcp/packages/server/data/api_types.yml @@ -86,6 +86,7 @@ Penpot: distributeVertical(shapes: Shape[]): void; flatten(shapes: Shape[]): Path[]; createVariantFromComponents(shapes: Board[]): VariantContainer; + waitForLayoutUpdate(timeout?: number): Promise; } ``` @@ -880,6 +881,27 @@ Penpot: Returns VariantContainer The variant container created + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the layout settles, the promise is rejected. Defaults to + 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the layout is updated. It is + rejected with an Error, both on timeout and on an invalid timeout value. ActiveUser: overview: |- Interface ActiveUser @@ -1118,9 +1140,9 @@ Board: tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -1185,6 +1207,7 @@ Board: applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; + waitForLayoutUpdate(timeout?: number): Promise; } ``` @@ -1533,9 +1556,9 @@ Board: readonly tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -2175,6 +2198,27 @@ Board: Removes the shape from its parent. Returns void + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the shape's layout settles, the promise is rejected. + Defaults to 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the shape's layout is updated. It + is rejected with an Error, both on timeout and on an invalid timeout value. VariantContainer: overview: |- Interface VariantContainer @@ -2271,9 +2315,9 @@ VariantContainer: tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -2338,6 +2382,7 @@ VariantContainer: applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; + waitForLayoutUpdate(timeout?: number): Promise; } ``` @@ -2689,9 +2734,9 @@ VariantContainer: readonly tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -3331,6 +3376,27 @@ VariantContainer: Removes the shape from its parent. Returns void + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the shape's layout settles, the promise is rejected. + Defaults to 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the shape's layout is updated. It + is rejected with an Error, both on timeout and on an invalid timeout value. Boolean: overview: |- Interface Boolean @@ -3414,9 +3480,9 @@ Boolean: tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -3481,6 +3547,7 @@ Boolean: applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; + waitForLayoutUpdate(timeout?: number): Promise; } ``` @@ -3786,9 +3853,9 @@ Boolean: readonly tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -4375,6 +4442,27 @@ Boolean: Removes the shape from its parent. Returns void + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the shape's layout settles, the promise is rejected. + Defaults to 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the shape's layout is updated. It + is rejected with an Error, both on timeout and on an invalid timeout value. CloseOverlay: overview: |- Interface CloseOverlay @@ -4742,9 +4830,9 @@ CommonLayout: rightPadding: number; bottomPadding: number; leftPadding: number; - horizontalSizing: "fill" - | "auto" - | "fix"; + paddingType: "simple" + | "multiple"; + horizontalSizing: "fill" | "auto" | "fix"; verticalSizing: "fill" | "auto" | "fix"; remove(): void; } @@ -4873,6 +4961,16 @@ CommonLayout: ``` The `leftPadding` property specifies the padding at the left of the container. + paddingType: |- + ``` + paddingType: "simple" | "multiple" + ``` + + The `paddingType` property specifies how the four padding values are applied. + It can be one of the following values: + + * 'simple': the vertical and horizontal paddings are mirrored across both sides. + * 'multiple': each of the four sides (top, right, bottom, left) is honoured independently. horizontalSizing: |- ``` horizontalSizing: "fill" | "auto" | "fix" @@ -4979,6 +5077,7 @@ Context: distributeVertical(shapes: Shape[]): void; flatten(shapes: Shape[]): Path[]; createVariantFromComponents(shapes: Board[]): VariantContainer; + waitForLayoutUpdate(timeout?: number): Promise; } ``` members: @@ -5666,6 +5765,27 @@ Context: Returns VariantContainer The variant container created + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the layout settles, the promise is rejected. Defaults to + 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the layout is updated. It is + rejected with an Error, both on timeout and on an invalid timeout value. ContextGeometryUtils: overview: |- Interface ContextGeometryUtils @@ -6074,9 +6194,9 @@ Ellipse: tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -6141,6 +6261,7 @@ Ellipse: applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; + waitForLayoutUpdate(timeout?: number): Promise; } ``` @@ -6418,9 +6539,9 @@ Ellipse: readonly tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -6952,6 +7073,27 @@ Ellipse: Removes the shape from its parent. Returns void + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the shape's layout settles, the promise is rejected. + Defaults to 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the shape's layout is updated. It + is rejected with an Error, both on timeout and on an invalid timeout value. EventsMap: overview: |- Interface EventsMap @@ -7086,7 +7228,11 @@ File: pages: Page[]; export( exportType: "penpot" | "zip", - libraryExportType?: "all" | "merge" | "detach", + libraryExportType?: + | "include-libraries" + | "merge-libraries" + | "detach-libraries" + | "link-later", ): Promise>; findVersions(criteria?: { createdBy: User }): Promise; saveVersion(label: string): Promise; @@ -7137,7 +7283,11 @@ File: ``` export( exportType: "penpot" | "zip", - libraryExportType?: "all" | "merge" | "detach", + libraryExportType?: + | "include-libraries" + | "merge-libraries" + | "detach-libraries" + | "link-later", ): Promise> ``` @@ -7146,13 +7296,13 @@ File: Parameters * exportType: "penpot" | "zip" - * libraryExportType: "all" | "merge" | "detach" + * libraryExportType: "include-libraries" | "merge-libraries" | "detach-libraries" | "link-later" Returns Promise> Example ``` - const exportedData = await file.export('penpot', 'all'); + const exportedData = await file.export('penpot', 'include-libraries'); ``` findVersions: |- ``` @@ -7578,9 +7728,9 @@ FlexLayout: rightPadding: number; bottomPadding: number; leftPadding: number; - horizontalSizing: "fill" - | "auto" - | "fix"; + paddingType: "simple" + | "multiple"; + horizontalSizing: "fill" | "auto" | "fix"; verticalSizing: "fill" | "auto" | "fix"; remove(): void; dir: "row" | "row-reverse" | "column" | "column-reverse"; @@ -7713,6 +7863,16 @@ FlexLayout: ``` The `leftPadding` property specifies the padding at the left of the container. + paddingType: |- + ``` + paddingType: "simple" | "multiple" + ``` + + The `paddingType` property specifies how the four padding values are applied. + It can be one of the following values: + + * 'simple': the vertical and horizontal paddings are mirrored across both sides. + * 'multiple': each of the four sides (top, right, bottom, left) is honoured independently. horizontalSizing: |- ``` horizontalSizing: "fill" | "auto" | "fix" @@ -8136,9 +8296,9 @@ GridLayout: rightPadding: number; bottomPadding: number; leftPadding: number; - horizontalSizing: "fill" - | "auto" - | "fix"; + paddingType: "simple" + | "multiple"; + horizontalSizing: "fill" | "auto" | "fix"; verticalSizing: "fill" | "auto" | "fix"; remove(): void; dir: "row" | "column"; @@ -8280,6 +8440,16 @@ GridLayout: ``` The `leftPadding` property specifies the padding at the left of the container. + paddingType: |- + ``` + paddingType: "simple" | "multiple" + ``` + + The `paddingType` property specifies how the four padding values are applied. + It can be one of the following values: + + * 'simple': the vertical and horizontal paddings are mirrored across both sides. + * 'multiple': each of the four sides (top, right, bottom, left) is honoured independently. horizontalSizing: |- ``` horizontalSizing: "fill" | "auto" | "fix" @@ -8622,9 +8792,9 @@ Group: tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -8689,6 +8859,7 @@ Group: applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; + waitForLayoutUpdate(timeout?: number): Promise; } ``` @@ -8970,9 +9141,9 @@ Group: readonly tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -9570,6 +9741,27 @@ Group: Removes the shape from its parent. Returns void + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the shape's layout settles, the promise is rejected. + Defaults to 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the shape's layout is updated. It + is rejected with an Error, both on timeout and on an invalid timeout value. GuideColumn: overview: |- Interface GuideColumn @@ -9903,9 +10095,9 @@ Image: tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -9970,6 +10162,7 @@ Image: applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; + waitForLayoutUpdate(timeout?: number): Promise; } ``` @@ -10247,9 +10440,9 @@ Image: readonly tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -10781,6 +10974,27 @@ Image: Removes the shape from its parent. Returns void + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the shape's layout settles, the promise is rejected. + Defaults to 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the shape's layout is updated. It + is rejected with an Error, both on timeout and on an invalid timeout value. Interaction: overview: |- Interface Interaction @@ -10917,6 +11131,7 @@ LayoutChildProperties: rightMargin: number; bottomMargin: number; leftMargin: number; + marginType: "simple" | "multiple"; maxWidth: number | null; maxHeight: number | null; minWidth: number | null; @@ -11015,6 +11230,16 @@ LayoutChildProperties: Sets the left margin of the child element. This is the space to the left of the element. + marginType: |- + ``` + marginType: "simple" | "multiple" + ``` + + The `marginType` property specifies how the four margin values are applied. + It can be one of the following values: + + * 'simple': the vertical and horizontal margins are mirrored across both sides. + * 'multiple': each of the four sides (top, right, bottom, left) is honoured independently. maxWidth: |- ``` maxWidth: number | null @@ -12950,9 +13175,9 @@ Page: nameLike?: string; type?: | "boolean" - | "path" | "ellipse" | "image" + | "path" | "text" | "group" | "board" @@ -13066,9 +13291,9 @@ Page: nameLike?: string; type?: | "boolean" - | "path" | "ellipse" | "image" + | "path" | "text" | "group" | "board" @@ -13088,9 +13313,9 @@ Page: nameLike?: string; type?: | "boolean" - | "path" | "ellipse" | "image" + | "path" | "text" | "group" | "board" @@ -13432,9 +13657,9 @@ Path: tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -13499,6 +13724,7 @@ Path: applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; + waitForLayoutUpdate(timeout?: number): Promise; } ``` @@ -13798,9 +14024,9 @@ Path: readonly tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -14346,6 +14572,27 @@ Path: Removes the shape from its parent. Returns void + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the shape's layout settles, the promise is rejected. + Defaults to 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the shape's layout is updated. It + is rejected with an Error, both on timeout and on an invalid timeout value. PathCommand: overview: |- Interface PathCommand @@ -14791,9 +15038,9 @@ Rectangle: tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -14858,6 +15105,7 @@ Rectangle: applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; + waitForLayoutUpdate(timeout?: number): Promise; } ``` @@ -15135,9 +15383,9 @@ Rectangle: readonly tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -15669,6 +15917,27 @@ Rectangle: Removes the shape from its parent. Returns void + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the shape's layout settles, the promise is rejected. + Defaults to 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the shape's layout is updated. It + is rejected with an Error, both on timeout and on an invalid timeout value. RulerGuide: overview: |- Interface RulerGuide @@ -15869,9 +16138,9 @@ ShapeBase: tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -15936,6 +16205,7 @@ ShapeBase: applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; + waitForLayoutUpdate(timeout?: number): Promise; } ``` @@ -16212,9 +16482,9 @@ ShapeBase: readonly tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -16746,6 +17016,27 @@ ShapeBase: Removes the shape from its parent. Returns void + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the shape's layout settles, the promise is rejected. + Defaults to 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the shape's layout is updated. It + is rejected with an Error, both on timeout and on an invalid timeout value. Slide: overview: |- Interface Slide @@ -16756,7 +17047,7 @@ Slide: ``` interface Slide { type: "slide"; - way: "in" | "out"; + way: "out" | "in"; direction: "left" | "right" | "up" | "down"; duration: number; offsetEffect?: boolean; @@ -16775,7 +17066,7 @@ Slide: Type of the animation. way: |- ``` - readonly way: "in" | "out" + readonly way: "out" | "in" ``` Indicate if the slide will be either in-to-out `in` or out-to-in `out`. @@ -16824,6 +17115,7 @@ Stroke: strokeCapStart?: StrokeCap; strokeCapEnd?: StrokeCap; strokeColorGradient?: Gradient; + strokeImage?: ImageData; } ``` @@ -16891,6 +17183,12 @@ Stroke: ``` The optional gradient stroke defined by a Gradient object. + strokeImage: |- + ``` + strokeImage?: ImageData + ``` + + The optional image stroke defined by an ImageData object. SvgRaw: overview: |- Interface SvgRaw @@ -16967,9 +17265,9 @@ SvgRaw: tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -17034,6 +17332,7 @@ SvgRaw: applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; + waitForLayoutUpdate(timeout?: number): Promise; type: "svg-raw"; } ``` @@ -17304,9 +17603,9 @@ SvgRaw: readonly tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -17844,6 +18143,27 @@ SvgRaw: Removes the shape from its parent. Returns void + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the shape's layout settles, the promise is rejected. + Defaults to 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the shape's layout is updated. It + is rejected with an Error, both on timeout and on an invalid timeout value. Text: overview: |- Interface Text @@ -17920,9 +18240,9 @@ Text: tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -17987,6 +18307,7 @@ Text: applyToken(token: Token, properties?: TokenProperty[]): void; clone(): Shape; remove(): void; + waitForLayoutUpdate(timeout?: number): Promise; type: "text"; characters: string; growType: "fixed" | "auto-width" | "auto-height"; @@ -18275,9 +18596,9 @@ Text: readonly tokens: { width: string; height: string; - fill: string; x: string; y: string; + fill: string; all: string; borderRadiusTopLeft: string; borderRadiusTopRight: string; @@ -18917,6 +19238,27 @@ Text: Removes the shape from its parent. Returns void + waitForLayoutUpdate: |- + ``` + waitForLayoutUpdate(timeout?: number): Promise + ``` + + This method returns a promise that will be resolved when all the + pending layout updates have finished and the components have synchronized. + If no layout work is pending the promise resolves immediately. + + Parameters + + * timeout: number + + Maximum time to wait, in milliseconds. If the timeout + elapses before the shape's layout settles, the promise is rejected. + Defaults to 30000; the promise never waits indefinitely. + + Returns Promise + + The promise to be resolved when the shape's layout is updated. It + is rejected with an Error, both on timeout and on an invalid timeout value. getRange: |- ``` getRange(start: number, end: number): TextRange @@ -22066,6 +22408,7 @@ TokenCatalog: ``` interface TokenCatalog { + isEditableTokens: boolean; themes: TokenTheme[]; sets: TokenSet[]; addTheme(group: { group: string; name: string }): TokenTheme; @@ -22078,6 +22421,15 @@ TokenCatalog: Referenced by: Library members: Properties: + isEditableTokens: |- + ``` + readonly isEditableTokens: boolean + ``` + + Returns true if the tokens can be edited (it can't + if the tokens of the file are in an external library). + Note that even in this case, Themes and Sets status still + can be changed and tokens can be applied to shapes. themes: |- ``` readonly themes: TokenTheme[] @@ -22884,7 +23236,7 @@ ImageData: } ``` - Referenced by: Color, Context, Fill, LibraryColor, Penpot + Referenced by: Color, Context, Fill, LibraryColor, Penpot, Stroke members: Properties: name: |- From b6a108c45660b07e22419e8913e03791a8f1de76 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 5 Oct 2026 09:54:46 +0000 Subject: [PATCH 14/14] :books: Restrict new `!` suffix to app.db and transients Default is now no `!` for new functions. Existing `!` stays as legacy until its gradual refactor. Allowed cases live in clojure/idioms: mutating public app.db API and functions working over transients. Any further case must be registered there first. AI-assisted-by: muse-spark-1.3-contributor --- .serena/memories/clojure/design-rules.md | 2 +- .serena/memories/clojure/idioms.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.serena/memories/clojure/design-rules.md b/.serena/memories/clojure/design-rules.md index 48c7f40193..edf273e152 100644 --- a/.serena/memories/clojure/design-rules.md +++ b/.serena/memories/clojure/design-rules.md @@ -5,7 +5,7 @@ How to shape a function in this codebase. Each rule came from a review round; do - **Preconditions belong to the boundary, not to the core.** A function documents what it needs (hints, docstring) and assumes it. Absence checks live where the value enters the system: the caller that resolves optional objects, `ig/init-key`, the request handler. A `(when (and (some? a) (some? b)) ...)` inside a leaf function means the guard is in the wrong place — if you cannot get `a` or `b`, you should not be calling it. - **Optional-by-design data is guarded where the optionality is born.** Example: `ConnectorStatistics` is nil when the server option is off, so the guard is `when-let [cs (some-> server ...)]` at the entry point and the consumer assumes `cs`. - **A static set of operations is written statically.** With a fixed, small set (four metrics) write the four calls. Do not build a collection and iterate it (`doseq` over a literal vector): it allocates, adds indirection and hides each operation. Use a collection when the set is dynamic (config, registry, input). -- **The name says what the function is; `!` says it mutates.** `!` marks functions whose contract is to change state or run a command (`run!`, `submit!`, `swap!`, `shutdown-now`). Reporting helpers (log, sample-and-publish) do not take it: the mutation happens in the `!` API they call. Keep a family consistent. +- **Default to no `!`; existing `!` is legacy being removed gradually.** Do not introduce new `!` names except the allowed cases documented in `mem:clojure/idioms` (add and justify the special case there first). Allowed: mutating public fns in `app.db` (`insert!`, `update!`, `delete!`, `run!`, `tx-run!`, ...) and fns whose contract is to work over transients (stdlib `conj!`/`assoc!`/`dissoc!`/`persistent!` and custom helpers over them). Existing `!` elsewhere (e.g. `emit!`, `send!`, `submit!`, `validate-file!`) stays until its refactor; keep a family consistent in the meantime. Reporting helpers (log, sample-and-publish) never take `!`: the mutation happens in the `!` API they call. - **Do not shape production code for tests.** No return values added just to assert them; assert on the observable effect. When preconditions move, move the tests with them. Reference implementation: the http metrics samplers in `app.http` (`sample-worker-metrics` / `sample-connector-metrics` are guard-free; `sample-http-metrics` guards at the boundary). diff --git a/.serena/memories/clojure/idioms.md b/.serena/memories/clojure/idioms.md index 74a25326d9..a4e9f3af72 100644 --- a/.serena/memories/clojure/idioms.md +++ b/.serena/memories/clojure/idioms.md @@ -8,3 +8,4 @@ Behaviors confirmed against the language/stdlib — do not re-derive from assump - The analyzer reads `:async` only from the fn name meta and the `fn` operator meta; list-level meta is ignored (would make a MetaFn). `(fn ^:async [] …)` puts the meta on argv (pre/post only, NOT async). - Valid: `(defn ^:async f)`, `(defn- ^:async f)`, `(^:async fn [] …)` (the latter is what stock `t/async` generates itself). - `^:async` fns never throw synchronously (rejected promises instead); `try/catch/finally` supported; continuations are microtasks, timers and RxJS schedulers are macrotasks (FIFO). +- `!` suffix allowed cases (only exceptions to the no-new-`!` rule in `mem:clojure/design-rules`): mutating public `app.db` API (`insert!`, `update!`, `delete!`, `run!`, `tx-run!`, `exec!`, ...) and fns whose contract is to work over transients (stdlib `conj!`, `assoc!`, `dissoc!`, `disj!`, `persistent!` and custom helpers over them); any further case must be registered here with justification before use.