Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-09-01 12:45:17 +02:00
commit f5aad7b1ae
118 changed files with 5689 additions and 3068 deletions

View File

@ -3,11 +3,8 @@ description: Execute a ready plan end-to-end — create a GitHub issue, branch i
agent: build
---
# Implement Plan
This command is run once a plan is ready (for example, from plan mode). Execute
the plan already prepared in the current session context — it does not take
extra arguments. Follow these steps in order.
the plan already prepared in the current session context. Follow these steps in order.
## 1. Create the issue

View File

@ -1,25 +0,0 @@
Act as a senior software engineer and perform a thorough review.
## Instructions
1. **Determine what is being reviewed** from the provided context:
- **If it is a plan** (implementation plan, design document, task breakdown) → load the **`plan-review`** skill.
- **If it is code** (diff, PR, code change) → load the **`code-review`** skill.
2. Read `AGENTS.md` and follow its instructions for finding and reading all related testing documentation from memories before reviewing.
3. **Skip generated files, lockfile-only changes, and unrelated modifications** unless they introduce security risks.
4. Follow the loaded skill's process and produce its output format.
## Strong Rules
1. Do not invent problems. Every finding must be real and actionable.
2. Do not modify any code and do not create a commit — this command only reviews.
3. Be specific and constructive. "This could be better" is not helpful — explain why and how.
4. Prioritize by impact. One structural issue outweighs ten nits.
5. Missing tests are an issue, not a suggestion. Report as a severity-tagged finding — never as a recommendation.
## Context
$ARGUMENTS

View File

@ -106,6 +106,8 @@ For detailed security guidance, see `security-and-hardening`.
| **Low:** | Minor, optional | Author may ignore — formatting, style preferences |
| **Suggestion:** | Worth considering | Not required, but improves the code |
**Unique finding IDs.** Assign every finding a stable identifier: `F1`, `F2`, `F3`, … numbered in order of severity (Critical first, then High, Medium, Low, Suggestion). Use the ID everywhere the finding is mentioned — in section headers, in the verdict, in follow-up discussion. Never renumber within a review. Example: `**F3 (High)** — `app/validate.cljs:42` — duplicate branch logic…`.
For each finding, describe the circumstances under which it could fail: specific inputs, load conditions, timing, or user actions that trigger the problem. "This crashes when input is null" is actionable; "this might crash" is not.
Lead with what matters: correctness and security first, then structural issues, then everything else. A few high-conviction comments beat a long list.
@ -122,11 +124,11 @@ Briefly explain what the code does and give an overall assessment.
### Critical and High-Priority Issues
List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful.
List problems that could cause security incidents, data loss, crashes, incorrect behavior, or major performance degradation. Each finding gets its unique ID (`F1`, `F2`, …). For each: state the severity, identify the file/function/code section, explain why it's a problem, describe failure circumstances, and provide a concrete improvement with corrected code when useful.
### Other Findings
List medium- and low-priority issues, including maintainability and design concerns.
List medium- and low-priority issues, including maintainability and design concerns. Continue the ID sequence started above (`F3`, `F4`, …).
### Suggested Refactoring
@ -148,6 +150,8 @@ Choose one:
- **Approve with minor changes** — Good to merge after addressing low/medium issues
- **Request changes** — Critical or high issues must be resolved before merge
List the finding IDs the verdict depends on (e.g. "Request changes: F1, F4").
## Change Sizing
Small, focused changes are easier to review, faster to merge, and safer to deploy.
@ -231,25 +235,13 @@ For supply-chain risk triage, follow the `security-and-hardening` skill.
## Verification
After review is complete:
Before emitting the verdict, verify the change as it stands. This is the reviewer's own due diligence — it covers the state of the code at review time, not the later resolution of findings (fixing findings is the author's job; confirming them is a new review):
- [ ] All Critical issues are resolved
- [ ] All Required (no-prefix) changes are resolved or explicitly deferred with justification
- [ ] Tests pass
- [ ] Tests pass — run them yourself, don't trust the claim
- [ ] Build succeeds
- [ ] The verification story is documented (what changed, how it was verified)
- [ ] Dependency upgrades reviewed against changelog, isolated per package, verified by green suite
## Multi-Model Review Pattern
Use different models for different review perspectives:
```
Model A writes the code → Model B reviews → Model A addresses feedback → Human makes the final call
```
Different models have different blind spots.
## See Also
- For detailed security review guidance, see `security-and-hardening`

View File

@ -1,13 +1,13 @@
---
name: planner
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan (Context, Affected modules, Approach, Risks, Testing). Always output to the user; additionally save to .opencode/plans/YYYY-MM-DD-<title>.md.
description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user and save to .opencode/plans/YYYY-MM-DD-<title>.md.
---
# Planner
Read-only senior software architect role for Penpot. Produces structured
implementation plans that engineers or other agents can execute. Never writes
or modifies code.
implementation plans with task breakdowns that engineers or other agents can
execute. Never writes or modifies code.
## When to Use
@ -18,24 +18,29 @@ or modifies code.
- The user asks "how would I implement X?" or "what's involved in fixing Y?".
- The user is about to start non-trivial work and wants a bite-sized task
breakdown.
- A task feels too large or vague to start.
- Work needs to be parallelized across multiple agents or sessions.
Do **not** use this skill to actually implement anything — it is read-only.
**When NOT to use:** Single-file changes with obvious scope, or when the spec
already contains well-defined tasks.
## Role
You are a Senior Software Architect working on Penpot, an open-source design
tool. Your sole responsibility is planning and analysis — you do NOT write or
modify code.
You help users understand the Penpot codebase, design solutions, and produce
implementation plans that other agents or developers can execute. The plan
tells them what to build and how to verify it, task by task.
You help users understand the codebase, design solutions, and create detailed
implementation plans that other agents or developers can execute. Document
everything they need to know: which files to touch for each task, code patterns,
tests, and how to verify correctness. Apply DRY and KISS principles.
The implementer reads the project's agent docs (`AGENTS.md`, project memories
such as `mem:critical-info`, `mem:testing`, and each module's core memory)
before working. Reference those memories instead of re-explaining tooling,
conventions, or test design — explain in the plan only what they do not cover.
Do **not** suggest commit messages or commit names anywhere in your plans or
responses — committing is the developer's responsibility.
responses — committing is the implementer's responsibility.
## Required Reading Before Planning
## CRITICAL: Required Reading Before Planning
Before drafting any plan, work through the project's own guidance:
@ -50,6 +55,8 @@ Before drafting any plan, work through the project's own guidance:
Skipping this step is the #1 cause of incorrect or incomplete plans.
---
## The Planning Process
### Phase 1: Architecture Analysis
@ -64,16 +71,42 @@ Skipping this step is the #1 cause of incorrect or incomplete plans.
### Phase 2: Task Breakdown
Implementation order follows the monorepo's dependency graph:
`frontend -> common`, `backend -> common`, `exporter -> common`,
`frontend -> render-wasm`. Build shared foundations first, then layer
consumers on top.
#### Identify the Dependency Graph
Map what depends on what, following the monorepo's module dependency graph:
```
common (shared types, schemas — no deps)
├── backend (depends common)
│ ├── RPC handlers
│ └── persistence / migrations
├── frontend (depends common, render-wasm)
│ ├── UI components
│ └── state / API integration
├── exporter (depends common)
└── render-wasm (consumed by frontend)
```
Implementation order follows the dependency graph bottom-up: build shared
foundations first, then layer consumers on top.
#### Slice Vertically
Instead of building all of common, then all of backend, then all of frontend —
build one complete feature path at a time:
**Bad (horizontal slicing):**
```
Task 1: Build all common types
Task 2: Build all backend handlers
Task 3: Build all frontend components
```
**Good (vertical slicing):**
```
Task 1: common data types + schema ← foundation
Task 2: backend RPC handler + persistence
@ -89,39 +122,58 @@ Each task follows this structure:
```markdown
## Task [N]: [Short descriptive title]
**Description:** One paragraph explaining what this task accomplishes.
**Description:** One or two paragraphs explaining what this task accomplishes.
Should be clear and concise.
**Rationale:** Why this task exists and why this approach over the obvious
alternatives — design decisions, trade-offs, constraints discovered during
analysis. One or two sentences; skip only if genuinely trivial.
**Code sketch (optional):** Signature-, type-, or shape-level example when the
intended interface is non-obvious. Keep it short — a skeleton that fixes the
contract (function signature, model fields, error shape), never a full
implementation. Omit when the task is mechanical.
**Acceptance criteria:**
- [ ] [Specific, testable condition]
- [ ] [Specific, testable condition]
**Verification:**
- [ ] Tests pass (module-specific test command)
- [ ] Lint/formatter passes (module-specific check command)
- [ ] Relevant tests pass (module-specific test command).
- [ ] Lint/formatter passes (module-specific check command), if applicable.
- [ ] The core flow works end-to-end, if applicable.
**Dependencies:** [Task numbers this depends on, or "None"]
**Files likely touched:**
- `path/to/file.clj`
- `path/to/file_test.clj`
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
```
Replace "module-specific test command" with the actual commands for the module
(e.g. `clojure -M:dev:test` for backend/common, `npx shadow-cljs compile test && npx karma start` for frontend,
or the commands noted in the module's core memory).
(e.g. `clojure -M:dev:test` for backend/common,
`npx shadow-cljs compile test && npx karma start` for frontend, or the
commands noted in the module's core memory).
When possible, design each task with TDD in mind: acceptance criteria double
as a test list, and the natural first step of the task is writing those tests
before the implementation. Some tasks resist this (config, migrations, pure
wiring) — for those, keep the usual verification steps.
#### Estimate Scope
| Size | Files | Scope |
|------|-------|-------|
| **XS** | 1 | Single function, config change, or schema tweak |
| **S** | 1-2 | One handler or component method |
| **M** | 3-5 | One vertical feature slice |
| **L** | 5-8 | Multi-component feature |
| **XL** | 8+ | **Too large — break it down further** |
| Size | Files | Scope | Example |
|------|-------|-------|---------|
| **XS** | 1 | Single function, config change, or schema tweak | Add a validation rule |
| **S** | 1-2 | One handler or component method | Add a new RPC endpoint |
| **M** | 3-5 | One vertical feature slice | Bookmark CRUD with tests |
| **L** | 5-8 | Multi-component feature | Search with filtering and pagination |
| **XL** | 8+ | **Too large — break it down further** | — |
If a task is L or larger, break it into smaller tasks. Agents perform best on
S and M tasks.
If a task is XL, it should be broken into smaller tasks. Agents perform best
on S and M tasks.
**When to break a task down further:**
- It would take more than one focused session
@ -141,11 +193,11 @@ Arrange tasks so that:
Add explicit checkpoints with the relevant module commands:
```markdown
## Checkpoint: After Tasks 1-3
- [ ] All tests pass (module-specific command)
- [ ] Lint/format passes (module-specific command)
- [ ] Core flow works end-to-end
- [ ] Review with human before proceeding
### Checkpoint: After Tasks 1-3
- [ ] Relevant tests pass (module-specific command).
- [ ] The relevant build or compilation passes, if applicable.
- [ ] The core flow works end-to-end.
- [ ] Review with human before proceeding.
```
## Requirements
@ -159,7 +211,7 @@ Add explicit checkpoints with the relevant module commands:
- Apply DRY and KISS principles to the proposed implementation.
- Define a testing strategy aligned with each affected module's tooling.
- Every task must have acceptance criteria and verification steps.
- Checkpoints must exist between major phases.
- Checkpoints must exist after every 2-3 tasks.
## Constraints
@ -168,7 +220,8 @@ Add explicit checkpoints with the relevant module commands:
`.opencode/plans/`.
- You do **not** run builds, tests, linters, or any commands that modify state.
- You do **not** create git commits or interact with version control.
- You do **not** execute shell commands beyond read-only searches.
- You do **not** execute shell commands beyond read-only searches (`rg`, `ls`,
`find`, `cat`, `bat`).
- Your output is a structured plan or analysis, ready for handoff to an
engineer agent or developer.
@ -188,8 +241,9 @@ slug is lowercase, hyphen-separated, and a short summary of the task
(e.g. `add-batch-get-profiles-for-file-comments`). Create the
`.opencode/plans/` directory if it does not exist.
Always attempt the write. If the user explicitly provides a target file path,
use that path instead of the default.
IMPORTANT: The plan agent has write permission specifically for
`.opencode/plans/` — always attempt the write. If the user explicitly provides
a target file path, use that path instead of the default.
### Plan Document Template
@ -212,41 +266,75 @@ use that path instead of the default.
security implications.]
## Approach
[Step-by-step implementation plan with file paths, function names, and code
shape where applicable. Group steps into atomic, ordered tasks.]
[A short strategy summary: 3-5 sentences describing the overall approach and
the shape of the dependency graph (what depends on what, what gets built
first). High-level only — the task-by-task detail lives in the Task List.]
## Task List
### Phase 1: Foundation
- [ ] Task 1: ...
- [ ] Task 2: ...
Each task uses the full task structure defined in
[Write Tasks](#write-tasks) — description, rationale, acceptance criteria,
verification, dependencies, files, estimated scope, and optional code sketch.
Never reduce a task to a one-line checkbox; the plan must be self-contained
and executable without other context.
### Checkpoint: Phase 1
- [ ] Tests pass, lint/formatter clean (module-specific commands)
Tasks are a flat, ordered list — a plan is not a roadmap. Do not group tasks
into phases, milestones, or sprints; ordering and dependencies are already
captured per task. Insert a checkpoint after every 2-3 tasks.
### Phase 2: Core Features
- [ ] Task 3: ...
- [ ] Task 4: ...
## Task 1: [Short descriptive title]
### Checkpoint: Phase 2
- [ ] End-to-end flow works
**Description:** [What this task accomplishes.]
### Phase 3: Polish
- [ ] Task 5: ...
- [ ] Task 6: ...
**Rationale:** [Why this approach over the alternatives.]
### Checkpoint: Complete
- [ ] All acceptance criteria met
- [ ] Ready for review
**Acceptance criteria:**
- [ ] [Specific, testable condition]
## Testing Strategy
[How to verify: which test commands to run per module, what cases to cover,
manual verification steps, lint/format checks. Consult each module's core
memory for the exact commands.]
**Verification:**
- [ ] Relevant tests pass (module-specific command).
**Dependencies:** None
**Files likely touched:**
- `path/to/file`
**Estimated scope:** [XS: 1 file | S: 1-2 files | M: 3-5 files | L: 5+ files]
**Code sketch (optional):** [Short contract-level example, only if the shape
is non-obvious.]
## Task 2: [Short descriptive title]
[Same structure as Task 1.]
## Task 3: [Short descriptive title]
[Same structure as Task 1.]
### Checkpoint: After Tasks 1-3
- [ ] Relevant tests pass (module-specific command).
- [ ] The relevant build or compilation passes, if applicable.
- [ ] The core flow works end-to-end.
- [ ] Review with human before proceeding.
## Task 4: [Short descriptive title]
[Same structure as Task 1.]
## Task 5: [Short descriptive title]
[Same structure as Task 1.]
## Verification & Testing
[How to verify each task and the whole plan: the project's real test, lint,
build, and run commands (extracted during Required Reading), coverage
expectations, and manual checks. Consult each module's core memory for the
exact commands.]
## Parallelization Opportunities
- **Safe to parallelize:** Independent feature slices across separate
modules, tests for already-implemented features
modules, tests for already-implemented features, documentation
- **Must be sequential:** Shared common schema changes, database migrations
- **Needs coordination:** Features that share a contract (define the contract
first, then parallelize)
@ -259,13 +347,31 @@ When the plan is purely analytical (e.g. a code review or feasibility study
with no implementation), skip the **Approach** and **Task List** sections and
lead with **Findings** instead, keeping the rest of the structure.
## Common Rationalizations
| Rationalization | Reality |
|---|---|
| "I'll figure it out as I go" | That's how you end up with a tangled mess and rework. 10 minutes of planning saves hours. |
| "The tasks are obvious" | Write them down anyway. Explicit tasks surface hidden dependencies and forgotten edge cases. |
| "Planning is overhead" | Planning is the task. Implementation without a plan is just typing. |
| "I can hold it all in my head" | Context windows are finite. Written plans survive session boundaries and compaction. |
## Red Flags
- Delivering prose without a task breakdown
- Tasks that say "implement the feature" without acceptance criteria
- No verification steps in the plan
- All tasks are XL-sized
- No checkpoints between tasks
- Dependency order isn't considered
## Verification Checklist
Before starting implementation, confirm:
Before delivering the plan, confirm:
- [ ] Every task has acceptance criteria
- [ ] Every task has a verification step
- [ ] Task dependencies are identified and ordered correctly
- [ ] No task touches more than ~5 files
- [ ] Checkpoints exist between major phases
- [ ] The human has reviewed and approved the plan
- [ ] No task is XL or larger — break it down instead
- [ ] Checkpoints exist after every 2-3 tasks
- [ ] The plan is ready for human review

View File

@ -477,9 +477,15 @@ There are exactly two types:
release, but the PR is being released elsewhere — the fix may not
actually ship here.
2. **PR is in the milestone, but the issue it closes is in a different
milestone (or has no milestone).** The PR is being released here, but
the issue it fixes is being released in a different version (or never
tracked in a milestone) — the changelog pairing is misleading.
milestone.** The PR is being released here, but the issue it fixes is
being released in a different version — the changelog pairing is
misleading.
**Exception — issue with no milestone is NOT an anomaly.** Milestones
are only required for issues tracked in the "Main" project. A milestone
PR that closes an issue with no milestone references an issue from
another (probably private) project; that is expected and the issue is
not part of this changelog. Do not report it.
**Anything else is not an anomaly.** Other discrepancies (exclusion
labels on in-changelog issues, missing valid issues, unmerged PR
@ -635,6 +641,10 @@ for pr_num in sorted(changelog_prs):
if get_pr_milestone(pr_num) != MILESTONE: continue
for issue_num in pr.get('closing_issues', []):
issue_ms = get_issue_milestone(issue_num)
# No milestone = issue from another (probably private) project —
# milestones are only required for the "Main" project. Not an
# anomaly, and the issue never belongs in this changelog.
if issue_ms is None: continue
if issue_ms != MILESTONE:
anomalies_b.append({
'pr': pr_num,
@ -657,7 +667,7 @@ with open(OUTPUT, 'w') as f:
f.write('## Summary\n\n')
f.write(f'- **Issue in {MILESTONE}, referenced PR in different milestone or no milestone:** {n_a}\n')
f.write(f'- **PR in {MILESTONE}, closing issue in different milestone or no milestone:** {n_b}\n')
f.write(f'- **PR in {MILESTONE}, closing issue in a different milestone:** {n_b}\n')
f.write(f'- **Total anomalies:** {n_a + n_b}\n\n')
# --- Anomalies section ---
@ -686,7 +696,7 @@ with open(OUTPUT, 'w') as f:
f.write('\n')
if n_b:
f.write(f'\n### PR in {MILESTONE}, closing issue in different milestone or no milestone\n\n')
f.write(f'\n### PR in {MILESTONE}, closing issue in a different milestone\n\n')
by_pr = {}
for b in anomalies_b:
by_pr.setdefault(b['pr'], []).append(b)
@ -721,8 +731,11 @@ milestone mismatches between issues and their referenced PRs:
1. **Issue in milestone, referenced PR in different milestone or no milestone**
the changelog claims a fix here, but the PR is released elsewhere.
2. **PR in milestone, closing issue in different milestone or no milestone** —
2. **PR in milestone, closing issue in a different milestone** —
the PR is released here, but the issue it fixes belongs to another version.
(An issue with *no* milestone belongs to another, probably private,
project — milestones are only required on the "Main" project — so it is
neither an anomaly nor a changelog candidate.)
**Rule violations are not in the report** — they are workflow errors the
LLM must fix directly in `CHANGES.md` during step 6a (pre-flight checks).
@ -799,8 +812,11 @@ self-contained and clickable in any Markdown viewer.
- **Anomaly = milestone mismatch only.** The report contains only milestone
mismatches: (1) the issue is in this milestone but the referenced PR is
in a different milestone (or unassigned), and (2) the PR is in this
milestone but the issue it closes is in a different milestone (or
unassigned). These are anomalies because the changelog pairing is
milestone but the issue it closes is in a different milestone. An
*unassigned* (milestone-less) issue closed by a milestone PR is **not**
an anomaly: milestones are required only for the "Main" project, so such
issues come from another (probably private) project and are not changelog
candidates. These anomalies are reported because the changelog pairing is
*misleading* — the human needs to decide whether the milestone or the
changelog is wrong. All other discrepancies (exclusion labels, missing
valid issues, unmerged PR references, duplicates, stale milestone

View File

@ -6,6 +6,7 @@ Compose-based dev environment under `docker/devenv/`, driven by `manage.sh`. Par
- `penpotdev-infra`: shared `postgres`, `minio`, `minio-setup`, `mailer`, `ldap`. File: `docker-compose.infra.yml`.
- `penpotdev-wsN` (N=0,1,…): per-instance `main` + `redis` (Valkey). File: `docker-compose.main.yml`. ws0 (a.k.a. `main`) binds `$PWD`; ws1+ bind clones at `${PENPOT_WORKSPACES_DIR}/wsN/` (default `~/.penpot/penpot_workspaces/`), maintained by the developer.
- Optional overlay `docker-compose.opencode.yml`: added by `instance-compose` as an extra `-f` only when `PENPOT_OPENCODE_CONFIG_DIR` is set (i.e. `run-devenv --opencode-config-dir DIR` ran in this process). Bind-mounts the host dir at `/home/penpot/.config/opencode` (`:z`). Flag-only, per-call; not read from ambient env. Parser `parse-opencode-config-dir` absolutizes (`~`, realpath) because compose resolves relative bind sources against the compose file's dir. Only instances brought up with the flag get the mount.
- All projects join external network `penpot_shared`. Created idempotently by `ensure-devenv-network`, never removed by lifecycle commands.
## Source-of-truth files
@ -65,7 +66,7 @@ No `--delete` on the working-tree pass: gitignored caches in the workspace survi
## CLI surface
- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet).
- `run-devenv --agentic [--ws main|0|wsN|N] [--sync] [--serena-context CTX] [--opencode-config-dir DIR]`: bring one instance up. Agentic only — MCP and Serena windows are always created. Default target main. Errors out if the target is already running. `--sync` is rejected on main; on ws1+ it's optional (forced only when the workspace dir does not exist yet). `--opencode-config-dir DIR` bind-mounts DIR at `~/.config/opencode` in-container via the optional overlay above; mount applies at container creation, so changing it requires stop + re-run.
- `stop-devenv [--ws main|0|wsN|N] [--all]`: stop instances. Flags mutually exclusive. `--ws N` stops just that workspace. `--ws 0` or no flag stops ws0; shared infra shuts down only if no other instances remain. `--all` stops every ws highest-first then ws0, then infra.
- `run-devenv`: legacy alias, ws0 non-agentic attached.
- `attach-devenv [--ws main|0|wsN|N]`: pure attach. Fails fast if instance/session missing.

View File

@ -31,18 +31,121 @@
- Fix plugin API addTheme calls failing with the signature shown in the high-level overview [#10074](https://github.com/penpot/penpot/issues/10074) (PR: [#10359](https://github.com/penpot/penpot/pull/10359))
- Fix empty text shape not being deleted on editor exit [#10540](https://github.com/penpot/penpot/issues/10540) (PR: [#10541](https://github.com/penpot/penpot/pull/10541))
- Fix broken token pills showing wrong default state when not selected [#10524](https://github.com/penpot/penpot/issues/10524) (PR: [#10535](https://github.com/penpot/penpot/pull/10535))
- Replace hyphens with bullets in subscription benefits list [#10547](https://github.com/penpot/penpot/issues/10547) (PR: [#10523](https://github.com/penpot/penpot/pull/10523))
- Fix Chinese (zh-CN) translation showing wrong label for Intersection in board path menu (by @sawirricardo) [#10346](https://github.com/penpot/penpot/issues/10346) (PR: [#10381](https://github.com/penpot/penpot/pull/10381))
- Fix invalid formulas being accepted in numeric inputs (by @AKnassa) [#9581](https://github.com/penpot/penpot/issues/9581) (PR: [#10659](https://github.com/penpot/penpot/pull/10659))
- Fix radial gradient handles blowing up in size when rotated on ellipses (by @AKnassa) [#10069](https://github.com/penpot/penpot/issues/10069) (PR: [#10666](https://github.com/penpot/penpot/pull/10666))
- Fix plugin API validation errors being too generic to diagnose the failure (by @AKnassa) [#10072](https://github.com/penpot/penpot/issues/10072) (PR: [#10667](https://github.com/penpot/penpot/pull/10667))
- Fix crash with referential integrity error when deleting a component inside a grid (by @Alotor) [#10101](https://github.com/penpot/penpot/issues/10101) (PR: [#10956](https://github.com/penpot/penpot/pull/10956))
- Fix component copies not preserving rotation when the main component has changes [#10109](https://github.com/penpot/penpot/issues/10109) (PR: [#10574](https://github.com/penpot/penpot/pull/10574))
- Fix text width and height staying stale after setting growType in the plugin API [#10207](https://github.com/penpot/penpot/issues/10207) (PR: [#9898](https://github.com/penpot/penpot/pull/9898))
- Fix padding not painted until expanding the 4-sides padding option [#10278](https://github.com/penpot/penpot/issues/10278) (PR: [#10602](https://github.com/penpot/penpot/pull/10602))
- Fix files with custom fonts breaking with a referential integrity error when moved between teams (by @filipsajdak) [#10496](https://github.com/penpot/penpot/issues/10496) (PR: [#10837](https://github.com/penpot/penpot/pull/10837))
- Fix clicking overlapping comment bubbles zooming to 20000% without showing the comments [#10526](https://github.com/penpot/penpot/issues/10526) (PR: [#10543](https://github.com/penpot/penpot/pull/10543))
- Fix user menu subsections in the dashboard not closing when hovering away from the parent option (by @AKnassa) [#10549](https://github.com/penpot/penpot/issues/10549) (PR: [#10639](https://github.com/penpot/penpot/pull/10639))
- Fix self-hosted env-generated config.js being cached for 7 days so PENPOT_FLAGS changes did not reach already-cached browsers (by @filipsajdak) [#10556](https://github.com/penpot/penpot/issues/10556) (PR: [#11146](https://github.com/penpot/penpot/pull/11146))
- Fix color of selected text in light theme [#10570](https://github.com/penpot/penpot/issues/10570) (PR: [#10614](https://github.com/penpot/penpot/pull/10614))
- Fix margin input order being inconsistent with padding inputs and between collapsed and expanded states [#10578](https://github.com/penpot/penpot/issues/10578) (PR: [#10797](https://github.com/penpot/penpot/pull/10797))
- Fix uncaught DOMException when writing image/svg+xml content to the clipboard (by @AKnassa) [#10596](https://github.com/penpot/penpot/issues/10596) (PR: [#10663](https://github.com/penpot/penpot/pull/10663))
- Fix tick icons not aligned in the font selector [#10597](https://github.com/penpot/penpot/issues/10597) (PR: [#10774](https://github.com/penpot/penpot/pull/10774))
- Fix incorrect padding values when multiple shapes are selected [#10598](https://github.com/penpot/penpot/issues/10598) (PR: [#10602](https://github.com/penpot/penpot/pull/10602))
- Fix integrity errors related to variants not being repaired [#10606](https://github.com/penpot/penpot/issues/10606) (PR: [#10768](https://github.com/penpot/penpot/pull/10768))
- Fix changing password showing 'Password should be at least 8 characters' error on the old password field (by @AKnassa) [#10626](https://github.com/penpot/penpot/issues/10626) (PR: [#10661](https://github.com/penpot/penpot/pull/10661))
- Fix stroke caps disappearing when dragging [#10633](https://github.com/penpot/penpot/issues/10633) (PR: [#10634](https://github.com/penpot/penpot/pull/10634))
- Fix layout padding being saved as string after invalid input in multi-selection, causing persistence errors (by @niwinz) [#10638](https://github.com/penpot/penpot/issues/10638) (PR: [#10758](https://github.com/penpot/penpot/pull/10758))
- Fix inconsistent theme handling between Penpot and plugins [#10676](https://github.com/penpot/penpot/issues/10676) (PR: [#10677](https://github.com/penpot/penpot/pull/10677))
- Fix image stroke (strokeImage) support missing in the plugin API Stroke interface [#10682](https://github.com/penpot/penpot/issues/10682) (PR: [#10683](https://github.com/penpot/penpot/pull/10683))
- Fix SVG images not working as fill in the WebGL renderer [#10705](https://github.com/penpot/penpot/issues/10705) (PR: [#10707](https://github.com/penpot/penpot/pull/10707))
- Fix background blur not working on text shapes [#10706](https://github.com/penpot/penpot/issues/10706) (PR: [#10712](https://github.com/penpot/penpot/pull/10712))
- Fix background blur not applying on strokes [#10713](https://github.com/penpot/penpot/issues/10713) (PR: [#10716](https://github.com/penpot/penpot/pull/10716))
- Fix text shape with empty content breaking workspace updates [#10725](https://github.com/penpot/penpot/issues/10725) (PR: [#10731](https://github.com/penpot/penpot/pull/10731))
- Fix missing SVG option in the file filters when adding an image fill (by @LuBoys) [#10756](https://github.com/penpot/penpot/issues/10756) (PR: [#10771](https://github.com/penpot/penpot/pull/10771))
- Update onboarding image [#10779](https://github.com/penpot/penpot/issues/10779) (PR: [#10783](https://github.com/penpot/penpot/pull/10783))
- Fix main toolbar overlapping the grid edition bar [#10788](https://github.com/penpot/penpot/issues/10788) (PR: [#10789](https://github.com/penpot/penpot/pull/10789))
- Fix WASM renderer panic when the WebGL context is restored mid-reload [#10810](https://github.com/penpot/penpot/issues/10810) (PR: [#10824](https://github.com/penpot/penpot/pull/10824))
- Fix nginx frontend forwarding the client Host header to backend/exporter, breaking Istio strict mTLS routing (by @yamila-moreno) [#10835](https://github.com/penpot/penpot/issues/10835) (PR: [#11233](https://github.com/penpot/penpot/pull/11233))
- Fix tutorial templates with components causing errors [#10839](https://github.com/penpot/penpot/issues/10839)
- Fix plugin 'Try out' flow crashing when projects have not loaded yet [#10858](https://github.com/penpot/penpot/issues/10858) (PR: [#10859](https://github.com/penpot/penpot/pull/10859))
- Fix collapsed Fill color section on the design panel for new texts [#10860](https://github.com/penpot/penpot/issues/10860) (PR: [#10972](https://github.com/penpot/penpot/pull/10972))
- Fix grid item date tooltip in the project view showing 'Will be deleted' instead of creation date (by @0xTHAC0) [#10873](https://github.com/penpot/penpot/issues/10873) (PR: [#11161](https://github.com/penpot/penpot/pull/11161))
- Merge stop and start measurement shortcut to match current behavior [#10884](https://github.com/penpot/penpot/issues/10884) (PR: [#10906](https://github.com/penpot/penpot/pull/10906))
- Fix shape size badge displayed twice when a user with Viewer permissions selects a shape [#10893](https://github.com/penpot/penpot/issues/10893) (PR: [#10985](https://github.com/penpot/penpot/pull/10985))
- Fix main menu being covered by the toolbar [#10902](https://github.com/penpot/penpot/issues/10902) (PR: [#10926](https://github.com/penpot/penpot/pull/10926))
- Fix font family typography asset persisting across files in newly created text layers [#10925](https://github.com/penpot/penpot/issues/10925) (PR: [#11134](https://github.com/penpot/penpot/pull/11134))
- Fix error raised when editing justified text [#10944](https://github.com/penpot/penpot/issues/10944) (PR: [#10945](https://github.com/penpot/penpot/pull/10945))
- Fix MCP WebSocket proxy failing after penpot-mcp container restarts due to stale nginx DNS resolution (by @780Farva) [#10946](https://github.com/penpot/penpot/issues/10946) (PR: [#10947](https://github.com/penpot/penpot/pull/10947))
- Fix verification email address being unreadable due to low-contrast text on the register success page [#10950](https://github.com/penpot/penpot/issues/10950) (PR: [#10965](https://github.com/penpot/penpot/pull/10965))
- Fix image swatches displaying a wrong format in the color picker list view [#10951](https://github.com/penpot/penpot/issues/10951) (PR: [#10975](https://github.com/penpot/penpot/pull/10975))
- Fix text editor crashing when dropping dragged text after selecting all content [#10954](https://github.com/penpot/penpot/issues/10954) (PR: [#10959](https://github.com/penpot/penpot/pull/10959))
- Fix MCP tokens being usable as API access tokens [#10960](https://github.com/penpot/penpot/issues/10960) (PR: [#10962](https://github.com/penpot/penpot/pull/10962))
- Add size limit and rate limiting to the send-user-feedback endpoint [#10979](https://github.com/penpot/penpot/issues/10979) (PR: [#10990](https://github.com/penpot/penpot/pull/10990))
- Fix main menu not keeping alignment when the left sidebar is expanded [#10981](https://github.com/penpot/penpot/issues/10981) (PR: [#10986](https://github.com/penpot/penpot/pull/10986))
- Fix update-profile-props RPC method accepting undocumented keys [#10991](https://github.com/penpot/penpot/issues/10991) (PR: [#10992](https://github.com/penpot/penpot/pull/10992))
- Fix import-binfile RPC method schema accepting a file-id parameter [#10993](https://github.com/penpot/penpot/issues/10993) (PR: [#10994](https://github.com/penpot/penpot/pull/10994))
- Fix assemble-chunks session lookup ignoring the profile-id scope [#11011](https://github.com/penpot/penpot/issues/11011) (PR: [#11012](https://github.com/penpot/penpot/pull/11012))
- Validate font-id team ownership in create-font-variant [#11013](https://github.com/penpot/penpot/issues/11013) (PR: [#11014](https://github.com/penpot/penpot/pull/11014))
- Validate team ownership on file library link endpoints [#11015](https://github.com/penpot/penpot/issues/11015) (PR: [#11016](https://github.com/penpot/penpot/pull/11016))
- Limit object size allocation in the V1 binfile parser [#11017](https://github.com/penpot/penpot/issues/11017) (PR: [#11018](https://github.com/penpot/penpot/pull/11018))
- Limit recursion depth in the Fressian reader [#11019](https://github.com/penpot/penpot/issues/11019) (PR: [#11020](https://github.com/penpot/penpot/pull/11020))
- Limit concurrent imports in the import-binfile RPC method [#11023](https://github.com/penpot/penpot/issues/11023) (PR: [#11024](https://github.com/penpot/penpot/pull/11024))
- Validate content-type on management upload endpoints [#11025](https://github.com/penpot/penpot/issues/11025) (PR: [#11026](https://github.com/penpot/penpot/pull/11026))
- Fix webhook endpoints allowing unauthorized access via creator-id fallback [#11028](https://github.com/penpot/penpot/issues/11028) (PR: [#11029](https://github.com/penpot/penpot/pull/11029))
- Escape markdown in user-controlled fields of Mattermost error notifications [#11033](https://github.com/penpot/penpot/issues/11033) (PR: [#11034](https://github.com/penpot/penpot/pull/11034))
- Enforce file read permission check on asset endpoints [#11035](https://github.com/penpot/penpot/issues/11035) (PR: [#11036](https://github.com/penpot/penpot/pull/11036))
- Add accumulated storage byte quota for media uploads [#11037](https://github.com/penpot/penpot/issues/11037) (PR: [#11038](https://github.com/penpot/penpot/pull/11038))
- Add bounding box dimension limit to exports [#11041](https://github.com/penpot/penpot/issues/11041) (PR: [#11042](https://github.com/penpot/penpot/pull/11042))
- Sanitize embedded scripts in SVG uploads [#11043](https://github.com/penpot/penpot/issues/11043) (PR: [#11044](https://github.com/penpot/penpot/pull/11044))
- Fix duplicate file ID returning inconsistent error responses [#11045](https://github.com/penpot/penpot/issues/11045) (PR: [#11050](https://github.com/penpot/penpot/pull/11050))
- Enforce permission checks in WebSocket subscription handlers [#11052](https://github.com/penpot/penpot/issues/11052) (PR: [#11054](https://github.com/penpot/penpot/pull/11054))
- Fix 'something went wrong' popup when using incremental numerical input interaction [#11053](https://github.com/penpot/penpot/issues/11053) (PR: [#10794](https://github.com/penpot/penpot/pull/10794))
- Enforce password complexity validation on the backend [#11055](https://github.com/penpot/penpot/issues/11055) (PR: [#11059](https://github.com/penpot/penpot/pull/11059))
- Normalize string inputs before processing [#11060](https://github.com/penpot/penpot/issues/11060) (PR: [#11061](https://github.com/penpot/penpot/pull/11061))
- Add cooldown to avoid sending duplicate invitation emails [#11062](https://github.com/penpot/penpot/issues/11062) (PR: [#11063](https://github.com/penpot/penpot/pull/11063))
- Enable SSRF protection for organization SSO validation [#11064](https://github.com/penpot/penpot/issues/11064) (PR: [#11065](https://github.com/penpot/penpot/pull/11065))
- Fix clone-file-media-object allowing to clone media objects from files without read access [#11087](https://github.com/penpot/penpot/issues/11087) (PR: [#11090](https://github.com/penpot/penpot/pull/11090))
- Fix 404 error page logo not visible in dark mode [#11091](https://github.com/penpot/penpot/issues/11091) (PR: [#11167](https://github.com/penpot/penpot/pull/11167))
- Fix incorrect permission handling when creating an invitation [#11098](https://github.com/penpot/penpot/issues/11098) (PR: [#11099](https://github.com/penpot/penpot/pull/11099))
- Reject zero or negative total-chunks values in upload sessions [#11103](https://github.com/penpot/penpot/issues/11103) (PR: [#11104](https://github.com/penpot/penpot/pull/11104))
- Fix import-binfile accepting unsupported version values without validation [#11105](https://github.com/penpot/penpot/issues/11105) (PR: [#11107](https://github.com/penpot/penpot/pull/11107))
- Fix sessions remaining active on other devices after account deletion [#11114](https://github.com/penpot/penpot/issues/11114) (PR: [#11115](https://github.com/penpot/penpot/pull/11115))
- Use random UUIDs for share link IDs instead of a predictable scheme [#11116](https://github.com/penpot/penpot/issues/11116) (PR: [#11117](https://github.com/penpot/penpot/pull/11117))
- Fix plugin manifest fetch hanging indefinitely without timeout [#11119](https://github.com/penpot/penpot/issues/11119) (PR: [#11120](https://github.com/penpot/penpot/pull/11120))
- Use constant-time comparison for shared key authentication [#11121](https://github.com/penpot/penpot/issues/11121) (PR: [#11122](https://github.com/penpot/penpot/pull/11122))
- Fix ESC key not closing the comment input box after posting a comment in the workspace [#11128](https://github.com/penpot/penpot/issues/11128) (PR: [#11131](https://github.com/penpot/penpot/pull/11131))
- Fix token edit modal crashing when resolving tokens with group nodes [#11143](https://github.com/penpot/penpot/issues/11143) (PR: [#11144](https://github.com/penpot/penpot/pull/11144))
- Fix text editor crashing when pasting into an empty text shape [#11149](https://github.com/penpot/penpot/issues/11149) (PR: [#11150](https://github.com/penpot/penpot/pull/11150))
- Fix comment avatars appearing on top of rulers when scrolling the canvas (by @filipsajdak) [#11163](https://github.com/penpot/penpot/issues/11163) (PR: [#11168](https://github.com/penpot/penpot/pull/11168))
- Fix infinite loop of get-teams and get-team-members calls when granting team access from an email link [#11215](https://github.com/penpot/penpot/issues/11215) (PR: [#11223](https://github.com/penpot/penpot/pull/11223))
- Fix RPC requests bypassing rate limiting with fractional bucket refill intervals [#11253](https://github.com/penpot/penpot/issues/11253) (PR: [#11254](https://github.com/penpot/penpot/pull/11254))
- Fix tempfile bucket serving objects to any authenticated user instead of only the uploader [#11269](https://github.com/penpot/penpot/issues/11269) (PR: [#11270](https://github.com/penpot/penpot/pull/11270))
- Fix increasing a value by clicking and dragging in a numeric input [#11274](https://github.com/penpot/penpot/issues/11274) (PR: [#11334](https://github.com/penpot/penpot/pull/11334))
- Fix notification pill rendering unescaped HTML in the detail section when importing tokens [#11276](https://github.com/penpot/penpot/issues/11276) (PR: [#11275](https://github.com/penpot/penpot/pull/11275))
- Fix share-link holders reading pages outside the authorized scope via the get-page RPC command [#11281](https://github.com/penpot/penpot/issues/11281) (PR: [#11284](https://github.com/penpot/penpot/pull/11284))
- Fix incorrect permission handling when managing share links on a file [#11289](https://github.com/penpot/penpot/issues/11289) (PR: [#11290](https://github.com/penpot/penpot/pull/11290))
- Fix backend session remaining valid after logout when the auth-token cookie is replayed [#11316](https://github.com/penpot/penpot/issues/11316) (PR: [#11317](https://github.com/penpot/penpot/pull/11317))
- Fix get-team-invitation-token requiring only read permissions [#11358](https://github.com/penpot/penpot/issues/11358) (PR: [#11359](https://github.com/penpot/penpot/pull/11359))
### :sparkles: New features & Enhancements
- Group toolbar drawing tools into shape and free-draw flyouts [#9316](https://github.com/penpot/penpot/issues/9316) (PR: [#9480](https://github.com/penpot/penpot/pull/9480), [#10354](https://github.com/penpot/penpot/pull/10354))
- Add outline stroke to Paths [#9961](https://github.com/penpot/penpot/issues/9961) (PR: [#8677](https://github.com/penpot/penpot/pull/8677))
- Make throwValidationErrors default to true for v2 manifest plugins [#10401](https://github.com/penpot/penpot/issues/10401) (PR: [#10433](https://github.com/penpot/penpot/pull/10433))
- Add dedicated Line and Arrow drawing tools (by @davidv399) [#9145](https://github.com/penpot/penpot/issues/9145) (PR: [#9146](https://github.com/penpot/penpot/pull/9146))
- Refactor wasm rulers and UI state [#10116](https://github.com/penpot/penpot/issues/10116) (PR: [#10461](https://github.com/penpot/penpot/pull/10461))
- Improve team invitations modal in the dashboard [#10484](https://github.com/penpot/penpot/issues/10484) (PR: [#10459](https://github.com/penpot/penpot/pull/10459))
- Highlight the first matching font in the font list when searching (by @ai-mountain) [#3204](https://github.com/penpot/penpot/issues/3204) (PR: [#9512](https://github.com/penpot/penpot/pull/9512), [#10450](https://github.com/penpot/penpot/pull/10450))
- Preserve token references when copying and pasting properties instead of resolving them to values (by @AKnassa) [#9582](https://github.com/penpot/penpot/issues/9582) (PR: [#10665](https://github.com/penpot/penpot/pull/10665))
- Add waitForLayoutUpdate method to the plugin API [#10136](https://github.com/penpot/penpot/issues/10136) (PR: [#9898](https://github.com/penpot/penpot/pull/9898))
- Show and manage comments while designing in the workspace [#10239](https://github.com/penpot/penpot/issues/10239) (PR: [#10275](https://github.com/penpot/penpot/pull/10275))
- Simplify MCP server configuration for common MCP clients [#10355](https://github.com/penpot/penpot/issues/10355) (PR: [#10604](https://github.com/penpot/penpot/pull/10604))
- Remove misleading MCP client JSON snippet from the key-generated modal (by @Shlok1729) [#10399](https://github.com/penpot/penpot/issues/10399) (PR: [#10415](https://github.com/penpot/penpot/pull/10415))
- Preview font families in the font selector [#10403](https://github.com/penpot/penpot/issues/10403) (PR: [#10411](https://github.com/penpot/penpot/pull/10411))
- Remember expanded/collapsed state of token sets in the color tokens picker (session scope) [#10551](https://github.com/penpot/penpot/issues/10551) (PR: [#10864](https://github.com/penpot/penpot/pull/10864))
- Show token sets in reverse order by default in the color tokens picker (by @rhinocap) [#10552](https://github.com/penpot/penpot/issues/10552) (PR: [#10658](https://github.com/penpot/penpot/pull/10658))
- Add multi-selection and bulk delete support to pages in the workspace sitemap [#10580](https://github.com/penpot/penpot/issues/10580) (PR: [#10581](https://github.com/penpot/penpot/pull/10581))
- Add a grid/list view toggle for files in the dashboard [#10691](https://github.com/penpot/penpot/issues/10691) (PR: [#10692](https://github.com/penpot/penpot/pull/10692))
- Migrate Docker images to Docker Hardened Images (DHI) [#10720](https://github.com/penpot/penpot/issues/10720) (PR: [#10732](https://github.com/penpot/penpot/pull/10732), [#10733](https://github.com/penpot/penpot/pull/10733), [#10734](https://github.com/penpot/penpot/pull/10734))
- Adopt React Aria [#10802](https://github.com/penpot/penpot/issues/10802) (PR: [#10675](https://github.com/penpot/penpot/pull/10675))
- Add plugin API function for awaiting component updates beyond waitForLayoutUpdate [#10927](https://github.com/penpot/penpot/issues/10927) (PR: [#10964](https://github.com/penpot/penpot/pull/10964))
- Emit open-workspace-file audit event with file statistics on workspace load [#11106](https://github.com/penpot/penpot/issues/11106) (PR: [#11138](https://github.com/penpot/penpot/pull/11138))
## 2.17.2
### :bug: Bugs fixed
@ -3053,7 +3156,7 @@ is a number of cores)
- Enable penpot SVG metadata only when exporting complete files [Taiga #1914](https://tree.taiga.io/project/penpot/us/1914?milestone=295883)
- Export to PDF all artboards of one page [Taiga #1895](https://tree.taiga.io/project/penpot/us/1895)
- Go to a undo step clicking on a history element of the list [Taiga #1374](https://tree.taiga.io/project/penpot/us/1374)
- Increment font size by 10 with shift+arrows [1047](https://github.com/penpot/penpot/issues/1047)
- Increment font size by 10 with shift+arrows [#1047](https://github.com/penpot/penpot/issues/1047)
- New shortcut to detach components Ctrl+Shift+K [Taiga #1799](https://tree.taiga.io/project/penpot/us/1799)
- Set email inputs to type "email", to aid keyboard entry [Taiga #1921](https://tree.taiga.io/project/penpot/issue/1921)
- Use shift+move to move element orthogonally [#823](https://github.com/penpot/penpot/issues/823)

View File

@ -17,7 +17,7 @@
io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"}
io.lettuce/lettuce-core {:mvn/version "7.6.0.RELEASE"}
io.lettuce/lettuce-core {:mvn/version "7.7.0.RELEASE"}
;; Minimal dependencies required by lettuce, we need to include them
;; explicitly because clojure dependency management does not support
;; yet the BOM format.
@ -25,7 +25,7 @@
io.micrometer/micrometer-observation {:mvn/version "1.14.2"}
java-http-clj/java-http-clj {:mvn/version "0.4.3"}
com.google.guava/guava {:mvn/version "33.6.0-jre"}
com.google.guava/guava {:mvn/version "33.7.1-jre"}
funcool/yetti
{:git/tag "v11.10"
@ -40,32 +40,32 @@
nrepl/nrepl {:mvn/version "1.7.0"}
org.postgresql/postgresql {:mvn/version "42.7.13"}
org.xerial/sqlite-jdbc {:mvn/version "3.53.2.1"}
org.xerial/sqlite-jdbc {:mvn/version "3.53.4.0"}
com.zaxxer/HikariCP {:mvn/version "7.1.0"}
io.whitfin/siphash {:mvn/version "2.0.0"}
io.whitfin/siphash {:mvn/version "3.0.0"}
buddy/buddy-hashers {:mvn/version "2.0.167"}
buddy/buddy-sign {:mvn/version "3.6.1-359"}
org.passay/passay {:mvn/version "1.6.6"}
org.passay/passay {:mvn/version "2.0.0"}
com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"}
org.jsoup/jsoup {:mvn/version "1.23.1"}
org.jsoup/jsoup {:mvn/version "1.23.2"}
at.yawk.lz4/lz4-java
{:mvn/version "1.11.1"}
{:mvn/version "1.11.2"}
org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"}
dawran6/emoji {:mvn/version "0.2.0"}
markdown-clj/markdown-clj {:mvn/version "1.12.8"}
markdown-clj/markdown-clj {:mvn/version "1.12.9"}
;; Pretty Print specs
pretty-spec/pretty-spec {:mvn/version "0.1.4"}
software.amazon.awssdk/s3 {:mvn/version "2.50.1"}
software.amazon.awssdk/sts {:mvn/version "2.50.1"}
software.amazon.awssdk/s3 {:mvn/version "2.54.5"}
software.amazon.awssdk/sts {:mvn/version "2.54.5"}
com.ladybugdb/lbug {:mvn/version "0.19.1"}
;; Required by Arrow RootAllocator (lbug only pulls arrow-memory-core).

View File

@ -14,10 +14,21 @@
:iterations 3
:parallelism 2})
(def ^:private weak-options
{:alg :pbkdf2+sha256
:iterations 100})
(defn derive-password
[password]
(hashers/derive password default-options))
(defn derive-password-weak
"Derives a password using a fast algorithm (pbkdf2+sha256, 100 iterations).
Intended for demo users only they are already gated behind the
`demo-users` config flag which is disabled in production."
[password]
(hashers/derive password weak-options))
(defn verify-password
[attempt password]
(try

View File

@ -9,7 +9,9 @@
(:require
[app.common.exceptions :as ex])
(:import
[org.passay CharacterCharacteristicsRule CharacterRule EnglishCharacterData PasswordData]))
[org.passay PasswordData]
[org.passay.data EnglishCharacterData]
[org.passay.rule CharacterCharacteristicsRule CharacterRule]))
(defonce ^:private passay-code->translation-key
{"INSUFFICIENT_LOWERCASE" "errors.weak-password.insufficient-lowercase"
@ -18,12 +20,13 @@
"INSUFFICIENT_SPECIAL" "errors.weak-password.insufficient-special"})
(defonce ^:private character-characteristics-rule
(doto (CharacterCharacteristicsRule.)
(.setRules [(CharacterRule. EnglishCharacterData/LowerCase 1)
(CharacterCharacteristicsRule.
4
(into-array org.passay.rule.CharacterRule
[(CharacterRule. EnglishCharacterData/LowerCase 1)
(CharacterRule. EnglishCharacterData/UpperCase 1)
(CharacterRule. EnglishCharacterData/Digit 1)
(CharacterRule. EnglishCharacterData/Special 1)])
(.setNumberOfCharacteristics 4)))
(CharacterRule. EnglishCharacterData/Special 1)])))
(defn validate-password
"Validates password strength.

View File

@ -42,6 +42,7 @@
[datoteka.io :as io])
(:import
java.io.File
java.io.FilterInputStream
java.io.InputStream
java.io.OutputStreamWriter
java.lang.AutoCloseable
@ -461,6 +462,31 @@
[^ZipFile input ^ZipEntry entry]
(.getInputStream input entry))
(defn- size-limiting-stream
"Wraps an InputStream to enforce a maximum number of decompressed bytes.
Raises :validation :max-file-size-reached when the limit is exceeded."
^InputStream
[^InputStream input ^long max-size]
(let [counter (atom 0)]
(proxy [FilterInputStream] [input]
(read
([]
(let [b (.read input)]
(when (pos? b)
(when (> (swap! counter inc) max-size)
(ex/raise :type :validation
:code :max-file-size-reached
:hint (str "stream exceeded max size: " max-size))))
b))
([buf off len]
(let [n (.read input buf off len)]
(when (pos? n)
(when (> (swap! counter + (long n)) max-size)
(ex/raise :type :validation
:code :max-file-size-reached
:hint (str "stream exceeded max size: " max-size))))
n))))))
(defn- zip-entry-reader
[^ZipFile input ^ZipEntry entry]
(-> (zip-entry-stream input entry)
@ -469,10 +495,12 @@
(defn- zip-entry-storage-content
"Wraps a ZipFile and ZipEntry into a penpot storage compatible
object and avoid creating temporal objects"
[input entry]
(let [hash (delay (->> entry
(zip-entry-stream input)
(sto.impl/calculate-hash)))]
[input entry & {:keys [max-size]}]
(let [stream-fn (fn []
(cond-> (zip-entry-stream input entry)
max-size (size-limiting-stream max-size)))
hash (delay (->> (stream-fn)
(sto.impl/calculate-hash)))]
(reify
sto.impl/IContentObject
(get-size [_]
@ -489,7 +517,7 @@
(throw (UnsupportedOperationException. "not implemented")))
(make-input-stream [_ _]
(zip-entry-stream input entry))
(stream-fn))
(make-output-stream [_ _]
(throw (UnsupportedOperationException. "not implemented"))))))
@ -884,9 +912,9 @@
ext (cmedia/mtype->extension (:content-type object))
path (str "objects/" id ext)
content (->> path
(get-zip-entry input)
(zip-entry-storage-content input))]
content (zip-entry-storage-content input
(get-zip-entry input path)
:max-size (::bfc/import-max-object-size cfg))]
(when (not= (:size object) (sto/get-size content))
(ex/raise :type :validation
@ -896,6 +924,15 @@
:expected-size (:size object)
:found-size (sto/get-size content)))
(when-let [max (::bfc/import-max-object-size cfg)]
(when (> (sto/get-size content) max)
(ex/raise :type :validation
:code :max-file-size-reached
:hint (str "storage object exceeds maximum size: " (sto/get-size content))
:path path
:max max
:found (sto/get-size content))))
(when-let [hash (get object :hash)]
(when (not= hash (sto/get-hash content))
(ex/raise :type :validation
@ -1117,6 +1154,15 @@
(let [manifest (-> (read-manifest input)
(validate-manifest))
entries (read-zip-entries input)
_ (when-let [max (::bfc/import-max-zip-entries cfg)]
(when (> (count entries) max)
(ex/raise :type :validation
:code :too-many-zip-entries
:hint (str "zip file has too many entries: " (count entries))
:max max
:found (count entries))))
cfg (-> cfg
(assoc ::entries entries)
(assoc ::manifest manifest)

View File

@ -94,7 +94,11 @@
;; SSRF protection
:ssrf-allowed-hosts #{}
:ssrf-extra-blocked-cidrs #{}})
:ssrf-extra-blocked-cidrs #{}
;; Binfile import limits
:binfile-import-max-object-size (* 1024 1024 100) ;; 100 MiB
:binfile-import-max-zip-entries (* 500 1000)}) ;; 500,000
(def schema:config
(do #_sm/optional-keys
@ -151,6 +155,10 @@
[:media-processing-service-uri {:optional true} ::sm/uri]
[:media-processing-service-timeout {:optional true} ::sm/int]
;; Binfile import limits (PENPOT_BINFILE_IMPORT_*)
[:binfile-import-max-object-size {:optional true} ::sm/int]
[:binfile-import-max-zip-entries {:optional true} ::sm/int]
[:deletion-delay {:optional true} ::ct/duration]
[:file-clean-delay {:optional true} ::ct/duration]
[:telemetry-enabled {:optional true} ::sm/boolean]

View File

@ -31,8 +31,8 @@
com.zaxxer.hikari.HikariDataSource
com.zaxxer.hikari.HikariPoolMXBean
com.zaxxer.hikari.metrics.prometheus.PrometheusMetricsTrackerFactory
io.whitfin.siphash.SipHasher
io.whitfin.siphash.SipHasherContainer
io.whitfin.siphash.SipHash
io.whitfin.siphash.SipHashContext
java.io.InputStream
java.io.OutputStream
java.sql.Connection
@ -701,12 +701,12 @@
;; --- Locks
(def ^:private siphash-state
(SipHasher/container
(uuid/get-bytes uuid/zero)))
(SipHash/context
(uuid/get-bytes uuid/zero)))
(defn uuid->hash-code
[o]
(.hash ^SipHasherContainer siphash-state
(.hash ^SipHashContext siphash-state
^bytes (uuid/get-bytes o)))
(defn- xact-check-param

View File

@ -370,7 +370,9 @@
::bfc/overwrite false
::bfc/profile-id profile-id
::bfc/project-id project-id
::bfc/input path)]
::bfc/input path
::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)
::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))]
(bf.v3/import-files! cfg)
{::yres/status 200
::yres/headers {"content-type" "text/plain"}
@ -626,7 +628,9 @@
::bfc/profile-id profile-id
::bfc/project-id project-id
::bfc/input path
::bfc/features (cfeat/get-team-enabled-features cf/flags team))]
::bfc/features (cfeat/get-team-enabled-features cf/flags team)
::bfc/import-max-object-size (cf/get :binfile-import-max-object-size)
::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries))]
(if (= format :binfile-v3)
(bf.v3/import-files! cfg)

View File

@ -400,6 +400,8 @@
:delete-object
(ig/ref :app.tasks.delete-object/handler)
:demo-purge
(ig/ref :app.tasks.demo-purge/handler)
:process-webhook-event
(ig/ref ::webhooks/process-event-handler)
:run-webhook
@ -437,6 +439,9 @@
:app.tasks.delete-object/handler
{::db/pool (ig/ref ::db/pool)}
:app.tasks.demo-purge/handler
{::db/pool (ig/ref ::db/pool)}
:app.tasks.file-gc/handler
{::db/pool (ig/ref ::db/pool)
::sto/storage (ig/ref ::sto/storage)}

View File

@ -103,7 +103,9 @@
(assoc ::bfc/project-id project-id)
(assoc ::bfc/profile-id profile-id)
(assoc ::bfc/team-id (:id team))
(assoc ::bfc/name name))
(assoc ::bfc/name name)
(assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size))
(assoc ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries)))
input-path (:path file)
owned? (some? upload-id)

View File

@ -231,8 +231,11 @@
::sm/params schema:get-comment-threads}
[cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(get-comment-threads conn profile-id file-id))))
(let [perms (files/check-comment-permissions! cfg profile-id file-id share-id)
threads (get-comment-threads conn profile-id file-id)]
(if (= :share-link (:type perms))
(filterv #(contains? (:pages perms) (:page-id %)) threads)
threads)))))
(defn- get-comment-threads-sql
[where]
@ -329,9 +332,15 @@
::sm/params schema:get-comment-thread}
[cfg {:keys [::rpc/profile-id file-id id share-id] :as params}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id])
(decode-row)))))
(let [perms (files/check-comment-permissions! cfg profile-id file-id share-id)
thread (some-> (db/exec-one! conn [sql:get-comment-thread profile-id file-id id])
(decode-row))]
(when (and thread (= :share-link (:type perms)))
(when-not (contains? (:pages perms) (:page-id thread))
(ex/raise :type :not-found
:code :object-not-found
:hint "not found")))
thread))))
;; --- COMMAND: Retrieve Comments
@ -348,8 +357,13 @@
::sm/params schema:get-comments}
[cfg {:keys [::rpc/profile-id thread-id share-id]}]
(db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
(let [{:keys [file-id]} (get-comment-thread conn thread-id)]
(files/check-comment-permissions! cfg profile-id file-id share-id)
(let [{:keys [file-id page-id]} (get-comment-thread conn thread-id)
perms (files/check-comment-permissions! cfg profile-id file-id share-id)]
(when (and (= :share-link (:type perms))
(not (contains? (:pages perms) page-id)))
(ex/raise :type :not-found
:code :object-not-found
:hint "not found"))
(get-comments conn thread-id)))))
(def sql:get-comments

View File

@ -7,9 +7,9 @@
(ns app.rpc.commands.demo
"A demo specific mutations."
(:require
[app.auth :refer [derive-password]]
[app.auth :refer [derive-password-weak]]
[app.common.exceptions :as ex]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.loggers.audit :as audit]
@ -17,6 +17,7 @@
[app.rpc.commands.auth :as auth]
[app.rpc.doc :as-alias doc]
[app.util.services :as sv]
[app.worker :as wrk]
[buddy.core.codecs :as bc]
[buddy.core.nonce :as bn]))
@ -34,8 +35,8 @@
:code :demo-users-not-allowed
:hint "Demo users are disabled by config."))
(let [sem (System/currentTimeMillis)
email (str "demo-" sem ".demo@example.com")
(let [sem (uuid/next)
email (str "demo-" sem "@demo.example.com")
fullname (str "Demo User " sem)
password (-> (bn/random-bytes 16)
@ -46,13 +47,17 @@
:fullname fullname
:is-active true
:is-demo true
:deleted-at (ct/in-future (cf/get-deletion-delay))
:password (derive-password password)
:password (derive-password-weak password)
:props {}}
profile (db/tx-run! cfg (fn [cfg]
(->> (auth/create-profile cfg params)
(auth/create-profile-rels cfg))))]
(wrk/submit! (-> cfg
(assoc ::wrk/task :demo-purge)
(assoc ::wrk/delay (cf/get-deletion-delay))
(assoc ::wrk/params {:profile-id (:id profile)})))
(with-meta {:email email
:password password}
{::audit/profile-id (:id profile)})))

View File

@ -95,18 +95,23 @@
(def check-read-permissions!
(perms/make-check-fn has-read-permissions?))
;; A user has comment permissions if she has read permissions, or
;; explicit comment permissions through the share-id
;; A user has comment permissions if:
;; - For :membership type: they have read permissions OR explicit comment permissions
;; - For :share-link type: they must have explicit comment permissions (who-comment=all)
;; This prevents share-link holders with who-comment=team from bypassing the restriction
(defn check-comment-permissions!
[cfg profile-id file-id share-id]
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
can-read (has-read-permissions? perms)
can-comment (has-comment-permissions? perms)]
(when-not (or can-read can-comment)
(let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
allowed? (if (= :share-link (:type perms))
(has-comment-permissions? perms)
(or (has-read-permissions? perms)
(has-comment-permissions? perms)))]
(when-not allowed?
(ex/raise :type :not-found
:code :object-not-found
:hint "not found"))))
:hint "not found"))
perms))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUERY COMMANDS

View File

@ -426,7 +426,9 @@
(assoc ::bfc/project-id project-id)
(assoc ::bfc/profile-id profile-id)
(assoc ::bfc/input template)
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team)))
(assoc ::bfc/features (cfeat/get-team-enabled-features cf/flags team))
(assoc ::bfc/import-max-object-size (cf/get :binfile-import-max-object-size))
(assoc ::bfc/import-max-zip-entries (cf/get :binfile-import-max-zip-entries)))
result (if (= format :binfile-v3)
(bf.v3/import-files! cfg)

View File

@ -141,9 +141,7 @@
(defn get-profile
"Get profile by id. Throws not-found exception if no profile found."
[conn id & {:as opts}]
;; NOTE: We need to set ::db/remove-deleted to false because demo profiles
;; are created with a set deleted-at value
(-> (db/get-by-id conn :profile id (assoc opts ::db/remove-deleted false))
(-> (db/get-by-id conn :profile id opts)
(decode-row)))
;; --- MUTATION: Update Profile (own)

View File

@ -944,8 +944,10 @@
::sm/params schema:delete-team-member
::db/transaction true}
[{:keys [::db/conn ::mbus/msgbus] :as cfg} {:keys [::rpc/profile-id team-id member-id] :as params}]
(let [team (get-team conn :profile-id profile-id :team-id team-id)
perms (get-permissions conn profile-id team-id)]
(let [team (get-team conn :profile-id profile-id :team-id team-id)
perms (get-permissions conn profile-id team-id)
members (get-team-members conn team-id)
member (d/seek #(= member-id (:id %)) members)]
(when-not (or (:is-owner perms)
(:is-admin perms))
(ex/raise :type :validation
@ -955,6 +957,15 @@
(ex/raise :type :validation
:code :cant-remove-yourself))
(when-not member
(ex/raise :type :not-found
:code :member-does-not-exist))
(when (and (:is-owner member)
(not (:is-owner perms)))
(ex/raise :type :validation
:code :cant-remove-owner))
(db/delete! conn :team-profile-rel {:profile-id member-id
:team-id team-id})

View File

@ -56,7 +56,7 @@
(assoc :can-read true)))
(defn- get-view-only-bundle
[{:keys [::db/conn] :as cfg} {:keys [profile-id file-id ::perms] :as params}]
[{:keys [::db/conn] :as cfg} {:keys [profile-id file-id share-id ::perms] :as params}]
(let [file (bfc/get-file cfg file-id)
project (db/get conn :project
@ -89,16 +89,18 @@
(mapv (fn [{:keys [id] :as lib}]
(merge lib (bfc/get-file cfg id)))))
links (->> (db/query conn :share-link {:file-id file-id})
(mapv (fn [row]
(-> row
(update :pages db/decode-pgarray #{})
;; NOTE: the flags are deprecated but are still present
;; on the table on old rows. The flags are pgarray and
;; for avoid decoding it (because they are no longer used
;; on frontend) we just dissoc the column attribute from
;; row.
(dissoc :flags)))))
links (cond->> (->> (db/query conn :share-link {:file-id file-id})
(mapv (fn [row]
(-> row
(update :pages db/decode-pgarray #{})
;; NOTE: the flags are deprecated but are still present
;; on the table on old rows. The flags are pgarray and
;; for avoid decoding it (because they are no longer used
;; on frontend) we just dissoc the column attribute from
;; row.
(dissoc :flags)))))
(= :share-link (:type perms))
(filterv #(= (:id %) share-id)))
fonts (db/query conn :team-font-variant
{:team-id (:id team)

View File

@ -1045,3 +1045,18 @@ RETURNING id, deleted_at;")
(update acc :created conj email)))))
{:created [] :skipped []}
emails)))))
;; ---- API: get-air-gapped
(def ^:private schema:get-air-gapped-result
[:map
[:air-gapped ::sm/boolean]])
(sv/defmethod ::get-air-gapped
"Returns whether this Penpot instance runs in air-gapped mode."
{::doc/added "2.18"
::sm/params [:map]
::sm/result schema:get-air-gapped-result
::rpc/auth false}
[_cfg _params]
{:air-gapped (contains? cf/flags :air-gapped-conf)})

View File

@ -0,0 +1,41 @@
;; 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 INC Sucursal en España SL
(ns app.tasks.demo-purge
"Task handler for delayed demo profile deletion. Submitted at demo
creation time with a delay matching the configured deletion-delay."
(:require
[app.common.logging :as l]
[app.common.time :as ct]
[app.db :as db]
[app.worker :as wrk]
[integrant.core :as ig]))
(defmethod ig/assert-key ::handler
[_ params]
(assert (db/pool? (::db/pool params)) "expected a valid database pool"))
(defmethod ig/init-key ::handler
[_ cfg]
(fn [{:keys [props]}]
(let [profile-id (get props :profile-id)
now (ct/now)]
(l/trc :hint "demo-purge" :profile-id (str profile-id))
;; Mark the profile for immediate deletion
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(db/update! conn :profile
{:deleted-at now}
{:id profile-id}
{::db/return-keys false})
(wrk/submit!
(-> cfg
(assoc ::wrk/task :delete-object)
(assoc ::wrk/params {:object :profile
:deleted-at now
:id profile-id}))))))))

View File

@ -13,6 +13,7 @@
[app.db :as db]
[app.features.fdata :as fdata]
[app.storage :as sto]
[app.tasks.delete-object :as dobj]
[integrant.core :as ig]))
(def ^:private sql:get-profiles
@ -33,6 +34,11 @@
;; Mark as deleted the storage object
(some->> photo-id (sto/touch-object! storage))
;; Cascade soft-delete to owned teams, projects, files, etc.
(dobj/delete-object cfg {:object :profile
:id id
:deleted-at timestamp})
(let [affected (-> (db/delete! conn :profile {:id id})
(db/get-update-count))]
(+ total affected)))

View File

@ -5,7 +5,12 @@
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.util.ssrf
"URL/host validation to prevent Server-Side Request Forgery."
"URL/host validation to prevent Server-Side Request Forgery.
The blocklist covers the standard JVM InetAddress classifications plus
explicit ranges: IPv6 ULA, IPv4-mapped loopback, cloud metadata,
operator-supplied CIDRs and the IPv6 transition mechanisms NAT64, 6to4
and Teredo."
(:require
[app.common.exceptions :as ex]
[app.common.logging :as l]
@ -122,6 +127,20 @@
;; Check the embedded IPv4 is loopback (127.x.x.x)
(= (bit-and (aget bs 12) 0xFF) 127))))
(defn- transition-prefix
"Classify a 16-byte IPv6 address into its transition mechanism:
:nat64 (64:ff9b::/96), :6to4 (2002::/16), :teredo (2001:0000::/32) or nil."
[^bytes bs]
(let [b0 (bit-and (aget bs 0) 0xFF)
b1 (bit-and (aget bs 1) 0xFF)
b2 (bit-and (aget bs 2) 0xFF)
b3 (bit-and (aget bs 3) 0xFF)]
(cond
(and (= b0 0x00) (= b1 0x64) (= b2 0xFF) (= b3 0x9B)) :nat64
(and (= b0 0x20) (= b1 0x02)) :6to4
(and (= b0 0x20) (= b1 0x01) (= b2 0x00) (= b3 0x00)) :teredo
:else nil)))
(defn- blocked-address?
"Check if an InetAddress should be blocked. Returns true if blocked."
[^InetAddress addr]
@ -141,12 +160,15 @@
;; Cloud metadata IPs (exact match)
(contains? cloud-metadata-ips (.getHostAddress addr))
;; Extra blocked CIDRs (IPv4 only)
;; Extra blocked CIDRs (IPv4 only) and IPv6 transition mechanisms
(let [bs (.getAddress addr)]
(if (= (alength bs) 4)
(or (some #(in-cidr4? bs %) extra-blocked-ranges)
(some #(in-cidr4? bs %) extra-blocked-cidrs))
false))))
;; IPv6 transition mechanisms (NAT64/6to4/Teredo): the range is
;; rejected outright.
(boolean (when (= (alength bs) 16)
(transition-prefix bs)))))))
(defn resolve-host
"Resolve a hostname to all InetAddress objects. Wraps InetAddress/getAllByName
@ -163,8 +185,10 @@
- host must resolve to at least one address, and
- **every** resolved address must NOT be in the blocklist
(loopback, link-local, site-local, multicast, any-local,
cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv4-mapped
IPv6 of any blocked IPv4, plus operator-supplied CIDRs).
cloud-metadata 169.254.169.254, IPv6 ULA fc00::/7, IPv6 transition
mechanisms NAT64 64:ff9b::/96, 6to4 2002::/16 and Teredo
2001:0000::/32, IPv4-mapped IPv6 of any blocked IPv4,
plus operator-supplied CIDRs).
When the host is an IP literal (decimal/octal/hex/IPv6) it is
normalized via `com.google.common.net.InetAddresses` before the
check.

View File

@ -27,6 +27,7 @@
[app.storage :as sto]
[app.storage.tmp :as tmp]
[backend-tests.helpers :as th]
[backend-tests.storage-test :as stt]
[clojure.test :as t]
[cuerdas.core :as str]
[datoteka.fs :as fs]
@ -1883,3 +1884,86 @@
(t/is (some? ext-libs)
"type :link-later should produce external-libraries even with include-libraries=true")
(t/is (pos? (count ext-libs))))))
(t/deftest import-rejects-too-many-zip-entries
;; import must reject ZIP files exceeding max-zip-entries
(let [profile (th/create-profile* 1)
file (prepare-simple-file profile)
output (tmp/tempfile :suffix ".zip")]
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{(:id file)})
(assoc ::bfc/export-type :detach-libraries))
(io/output-stream output))
;; Import with max-zip-entries=1 — the exported ZIP has more entries
(let [cfg (-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
(assoc ::bfc/profile-id (:id profile))
(assoc ::bfc/input output)
(assoc ::bfc/import-max-zip-entries 1))
out (try
(v3/import-files! cfg)
:no-error
(catch Throwable e
(let [d (or (ex-data e) (some-> (ex-cause e) ex-data))]
d)))]
(t/is (= :validation (:type out)))
(t/is (= :too-many-zip-entries (:code out))))))
(defn- prepare-file-with-media
"Creates a file with a media object backed by a real storage object,
so that v3 export produces objects/ entries."
[profile]
(let [storage (-> (:app.storage/storage th/*system*)
(stt/configure-storage-backend))
sobject (sto/put-object! storage {::sto/content (sto/content "media-bytes")
:content-type "image/svg+xml"
:bucket "file-media-object"})
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})
mobj (th/create-file-media-object* {:file-id (:id file)
:is-local true
:media-id (:id sobject)})]
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-media
:object mobj}])
(dissoc file :data)))
(t/deftest import-rejects-oversized-object
;; import must reject storage objects exceeding max-object-size
(let [profile (th/create-profile* 1)
file (prepare-file-with-media profile)
output (tmp/tempfile :suffix ".zip")]
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{(:id file)})
(assoc ::bfc/export-type :detach-libraries))
(io/output-stream output))
;; Import with max-object-size=1 — the media object will exceed this
(let [cfg (-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
(assoc ::bfc/profile-id (:id profile))
(assoc ::bfc/input output)
(assoc ::bfc/import-max-object-size 1))
out (try
(v3/import-files! cfg)
:no-error
(catch Throwable e
(let [d (or (ex-data e) (some-> (ex-cause e) ex-data))]
d)))]
(t/is (= :validation (:type out)))
(t/is (= :max-file-size-reached (:code out))))))

View File

@ -6,6 +6,7 @@
(ns backend-tests.db-test
(:require
[app.common.uuid :as uuid]
[app.db :as db]
[backend-tests.helpers :as th]
[clojure.test :as t])
@ -41,3 +42,13 @@
(t/testing "maximum pool size is reasonable"
(t/is (pos? (:maximum-pool-size stats))))))
(t/deftest uuid->hash-code-is-deterministic
(t/is (= (db/uuid->hash-code uuid/zero)
(db/uuid->hash-code uuid/zero))))
(t/deftest uuid->hash-code-returns-long
(t/is (instance? Long (db/uuid->hash-code uuid/zero))))
(t/deftest uuid->hash-code-stable-for-zero-uuid
(t/is (= 3659997967308761462 (db/uuid->hash-code uuid/zero))))

View File

@ -0,0 +1,47 @@
;; 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 INC Sucursal en España SL
(ns backend-tests.demo-test
(:require
[app.common.time :as ct]
[app.db :as db]
[app.rpc.commands.profile :as profile]
[app.tasks.demo-purge :as demo-purge]
[app.worker :as wrk]
[backend-tests.helpers :as th]
[clojure.test :as t]
[integrant.core :as ig]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest demo-profile-created-without-deleted-at
(let [profile (th/create-profile* 999 {:is-demo true})]
(t/is (true? (:is-demo profile)))
(t/is (nil? (:deleted-at profile)))
(t/is (some? (:id profile)))))
(t/deftest get-profile-finds-demo-user-without-override
(let [profile (th/create-profile* 998 {:is-demo true})
found (db/run! th/*pool*
(fn [{:keys [::db/conn]}]
(profile/get-profile conn (:id profile))))]
(t/is (some? found))
(t/is (= (:id profile) (:id found)))))
(t/deftest demo-purge-handler-submits-delete-object
(let [profile (th/create-profile* 996 {:is-demo true})
handler (ig/init-key :app.tasks.demo-purge/handler
{::db/pool th/*pool*})
submitted (atom nil)]
(with-redefs [wrk/submit! (fn [& {:keys [::wrk/task ::wrk/params]}]
(reset! submitted {:task task :params params}))]
(handler {:props {:profile-id (:id profile)
:deleted-at (ct/now)}}))
(t/is (= :delete-object (:task @submitted)))
(t/is (= :profile (:object (:params @submitted))))
(t/is (= (:id profile) (:id (:params @submitted))))
(t/is (some? (:deleted-at (:params @submitted))))))

View File

@ -0,0 +1,51 @@
;; 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 INC Sucursal en España SL
(ns backend-tests.passwords-test
(:require
[app.auth.passwords :as passwords]
[backend-tests.helpers :as th]
[clojure.test :as t]))
(defn- run-validation
[password]
(try
(passwords/validate-password password)
nil
(catch Throwable e
e)))
(t/deftest validate-password-accepts-strong-password
(t/is (nil? (run-validation "Str0ng!Pass"))))
(t/deftest validate-password-rejects-too-short-password
(let [error (run-validation "Ab1!x")]
(t/is (th/ex-of-code? error :weak-password))
(t/is (= ["errors.weak-password.too-short"] (:details (ex-data error))))))
(t/deftest validate-password-rejects-missing-lowercase
(let [error (run-validation "ABCDEFG1!")]
(t/is (th/ex-of-code? error :weak-password))
(t/is (= ["errors.weak-password.insufficient-lowercase"]
(:details (ex-data error))))))
(t/deftest validate-password-rejects-missing-uppercase
(let [error (run-validation "abcdefg1!")]
(t/is (th/ex-of-code? error :weak-password))
(t/is (= ["errors.weak-password.insufficient-uppercase"]
(:details (ex-data error))))))
(t/deftest validate-password-rejects-missing-digit
(let [error (run-validation "Abcdefgh!")]
(t/is (th/ex-of-code? error :weak-password))
(t/is (= ["errors.weak-password.insufficient-digits"]
(:details (ex-data error))))))
(t/deftest validate-password-rejects-missing-special
(let [error (run-validation "Abcdefgh1")]
(t/is (th/ex-of-code? error :weak-password))
(t/is (= ["errors.weak-password.insufficient-special"]
(:details (ex-data error))))))

View File

@ -285,3 +285,196 @@
(let [threads (th/db-query :comment-thread {:file-id (:id file-1)})]
(t/is (= 0 (count threads)))))))))
(t/deftest share-link-who-comment-team-cannot-comment
(let [owner (th/create-profile* 1 {:is-active true})
outsider (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:team-id (:id team)
:profile-id (:id owner)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
page-id (get-in file [:data :pages 0])
share (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{page-id}
:who-comment "team"
:who-inspect "all"})
share-id (get-in share [:result :id])]
(t/testing "outsider with who-comment=team share-link cannot get-comment-threads"
(let [out (th/command! {::th/type :get-comment-threads
::rpc/profile-id (:id outsider)
:file-id (:id file)
:share-id share-id})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))))
(t/testing "outsider with who-comment=team share-link cannot create-comment-thread"
(let [out (th/command! {::th/type :create-comment-thread
::rpc/profile-id (:id outsider)
:file-id (:id file)
:page-id page-id
:position (gpt/point 0)
:content "outsider comment"
:frame-id uuid/zero
:share-id share-id})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))))))
(t/deftest share-link-who-comment-all-can-comment
(let [owner (th/create-profile* 1 {:is-active true})
outsider (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:team-id (:id team)
:profile-id (:id owner)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
page-id (get-in file [:data :pages 0])
share (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{page-id}
:who-comment "all"
:who-inspect "all"})
share-id (get-in share [:result :id])]
(t/testing "outsider with who-comment=all share-link can get-comment-threads"
(let [out (th/command! {::th/type :get-comment-threads
::rpc/profile-id (:id outsider)
:file-id (:id file)
:share-id share-id})]
(t/is (th/success? out))))
(t/testing "outsider with who-comment=all share-link can create-comment-thread"
(let [out (th/command! {::th/type :create-comment-thread
::rpc/profile-id (:id outsider)
:file-id (:id file)
:page-id page-id
:position (gpt/point 0)
:content "outsider comment"
:frame-id uuid/zero
:share-id share-id})]
(t/is (th/success? out))))))
(t/deftest share-link-page-scope-enforced
(let [owner (th/create-profile* 1 {:is-active true})
outsider (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})
project (th/create-project* 1 {:team-id (:id team)
:profile-id (:id owner)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
page-a (get-in file [:data :pages 0])
page-b (uuid/random)
_ (th/command! {::th/type :update-file
::rpc/profile-id (:id owner)
:id (:id file)
:session-id (uuid/random)
:revn 0
:vern 0
:changes [{:type :add-page
:id page-b
:page {:id page-b
:name "Page B"
:options {}
:objects {}}}]})
thread-a (th/command! {::th/type :create-comment-thread
::rpc/profile-id (:id owner)
:file-id (:id file)
:page-id page-a
:position (gpt/point 0)
:content "comment on page A"
:frame-id uuid/zero})
thread-b (th/command! {::th/type :create-comment-thread
::rpc/profile-id (:id owner)
:file-id (:id file)
:page-id page-b
:position (gpt/point 0)
:content "comment on page B"
:frame-id uuid/zero})
thread-a-id (get-in thread-a [:result :id])
thread-b-id (get-in thread-b [:result :id])
share (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{page-a}
:who-comment "all"
:who-inspect "all"})
share-id (get-in share [:result :id])]
(t/testing "share-link holder can get-comment-threads for shared page only"
(let [out (th/command! {::th/type :get-comment-threads
::rpc/profile-id (:id outsider)
:file-id (:id file)
:share-id share-id})
result (:result out)]
(t/is (th/success? out))
(t/is (= 1 (count result)))
(t/is (= page-a (:page-id (first result))))))
(t/testing "share-link holder cannot get-comment-thread for unshared page"
(let [out (th/command! {::th/type :get-comment-thread
::rpc/profile-id (:id outsider)
:file-id (:id file)
:id thread-b-id
:share-id share-id})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))))
(t/testing "share-link holder can get-comment-thread for shared page"
(let [out (th/command! {::th/type :get-comment-thread
::rpc/profile-id (:id outsider)
:file-id (:id file)
:id thread-a-id
:share-id share-id})]
(t/is (th/success? out))))
(t/testing "share-link holder cannot get-comments for thread on unshared page"
(let [out (th/command! {::th/type :get-comments
::rpc/profile-id (:id outsider)
:thread-id thread-b-id
:share-id share-id})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))))))
(t/deftest membership-can-still-comment
(let [owner (th/create-profile* 1 {:is-active true})
member (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})
_ (th/create-team-role* {:team-id (:id team)
:profile-id (:id member)
:role :editor})
project (th/create-project* 1 {:team-id (:id team)
:profile-id (:id owner)})
file (th/create-file* 1 {:profile-id (:id owner)
:project-id (:id project)})
page-id (get-in file [:data :pages 0])]
(t/testing "team member can get-comment-threads without share-id"
(let [out (th/command! {::th/type :get-comment-threads
::rpc/profile-id (:id member)
:file-id (:id file)})]
(t/is (th/success? out))))
(t/testing "team member can create-comment-thread without share-id"
(let [out (th/command! {::th/type :create-comment-thread
::rpc/profile-id (:id member)
:file-id (:id file)
:page-id page-id
:position (gpt/point 0)
:content "member comment"
:frame-id uuid/zero})]
(t/is (th/success? out))))))

View File

@ -0,0 +1,40 @@
;; 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 INC Sucursal en España SL
(ns backend-tests.rpc-demo-test
(:require
[app.auth :as auth]
[app.config :as cf]
[backend-tests.helpers :as th]
[clojure.test :as t]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
;; Capture the real verifier before the shared test fixture replaces it.
(def verify-password* auth/verify-password)
(t/deftest weak-password-hash-verifies
(let [password "DemoPassword123!"
hashed (auth/derive-password-weak password)]
(t/is (:valid (verify-password* password hashed)))))
(t/deftest create-demo-profile-uses-unique-uuid-email
(with-redefs [cf/flags (conj cf/flags :demo-users)]
(let [first-result (th/command! {::th/type :create-demo-profile})
second-result (th/command! {::th/type :create-demo-profile})
first-profile (:result first-result)
second-profile (:result second-result)]
(t/is (nil? (:error first-result)))
(t/is (nil? (:error second-result)))
(t/is (re-matches #"demo-[0-9a-fA-F-]+@demo\.example\.com"
(:email first-profile)))
(t/is (not= (:email first-profile) (:email second-profile))))))
(t/deftest create-demo-profile-requires-feature-flag
(with-redefs [cf/flags (disj cf/flags :demo-users)]
(let [{:keys [error]} (th/command! {::th/type :create-demo-profile})]
(t/is (th/ex-of-code? error :demo-users-not-allowed)))))

View File

@ -59,8 +59,13 @@
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks total-chunks})]
(t/is (nil? (:error out)))
(:session-id (:result out))))
(let [session-id (:session-id (:result out))]
(t/is (nil? (:error out))
(str "create-upload-session failed: "
(some-> (:error out) ex-data)))
(t/is (uuid? session-id)
(str "create-upload-session returned an invalid session-id: " session-id))
session-id)))
(defn- upload-font-chunked!
"Splits `font-bytes` into chunks of `chunk-size` bytes, creates an upload
@ -68,14 +73,15 @@
[prof ^bytes font-bytes mtype chunk-size]
(let [chunks (split-bytes-into-chunks font-bytes chunk-size)
session-id (create-upload-session! prof (count chunks))]
(doseq [[idx chunk-data] (map-indexed vector chunks)]
(let [mfile (make-chunk-mfile chunk-data mtype)
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index idx
:content mfile})]
(t/is (nil? (:error out)))))
(when (uuid? session-id)
(doseq [[idx chunk-data] (map-indexed vector chunks)]
(let [mfile (make-chunk-mfile chunk-data mtype)
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index idx
:content mfile})]
(t/is (nil? (:error out))))))
session-id))
(defn- assert-font-variant-result
@ -613,43 +619,42 @@
;; N2-07: A user with edit permissions on their own team must not be
;; able to create a font variant using a font-id that already belongs
;; to another team (BOLA / CWE-639).
(with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}]
(let [prof1 (th/create-profile* 1 {:is-active true})
prof2 (th/create-profile* 2 {:is-active true})
team1 (:default-team-id prof1)
team2 (:default-team-id prof2)
font-id (uuid/custom 10 999)
data (-> (io/resource "backend_tests/test_files/font-1.ttf")
(io/read*))]
(let [prof1 (th/create-profile* 1 {:is-active true})
prof2 (th/create-profile* 2 {:is-active true})
team1 (:default-team-id prof1)
team2 (:default-team-id prof2)
font-id (uuid/custom 10 999)
data (-> (io/resource "backend_tests/test_files/font-1.ttf")
(io/read*))]
;; prof1 creates a font variant in team1 with font-id
(let [session-id (upload-font-chunked! prof1 data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof1)
:team-id team1
:font-id font-id
:font-family "SharedFont"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (nil? (:error out))))
;; prof1 creates a font variant in team1 with font-id
(let [session-id (upload-font-chunked! prof1 data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof1)
:team-id team1
:font-id font-id
:font-family "SharedFont"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (nil? (:error out))))
;; prof2 tries to create a variant using the same font-id but
;; in team2, which must be rejected because font-id belongs to team1
(let [session-id (upload-font-chunked! prof2 data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof2)
:team-id team2
:font-id font-id
:font-family "SharedFont"
:font-weight 700
:font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code)))))))
;; prof2 tries to create a variant using the same font-id but
;; in team2, which must be rejected because font-id belongs to team1
(let [session-id (upload-font-chunked! prof2 data "font/ttf" (* 4 1024 1024))
params {::th/type :create-font-variant
::rpc/profile-id (:id prof2)
:team-id team2
:font-id font-id
:font-family "SharedFont"
:font-weight 700
:font-style "normal"
:uploads {"font/ttf" session-id}}
out (th/command! params)]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code))))))
(t/deftest get-font-variants-nonexistent-file
(let [prof (th/create-profile* 1 {:is-active true})

View File

@ -194,6 +194,15 @@
(string? (get version k)))))
(t/is (= cf/version version))))
(t/deftest get-air-gapped
(let [out (th/management-command! {::th/type :get-air-gapped})]
(t/is (th/success? out))
(t/is (false? (-> out :result :air-gapped))))
(binding [cf/flags (conj cf/flags :air-gapped-conf)]
(let [out (th/management-command! {::th/type :get-air-gapped})]
(t/is (th/success? out))
(t/is (true? (-> out :result :air-gapped))))))
(t/deftest get-teams-returns-only-owned-non-default-non-deleted
(with-mocks [nitrate-mock {:target 'app.nitrate/call :return nil}]
(let [profile (th/create-profile* 1 {:is-active true})

View File

@ -424,6 +424,38 @@
(let [count-after (:count (th/db-exec-one! ["SELECT count(*) FROM http_session_v2 WHERE profile_id = ?" (:id prof)]))]
(t/is (= 0 count-after)))))
(t/deftest profile-deletion-via-gc-cascades
(let [prof (th/create-profile* 1)
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
team-id (:default-team-id prof)
project-id (:default-project-id prof)
file-id (:id file)
deleted-at (ct/minus (ct/now) (ct/duration {:days 1}))]
(th/db-update! :profile
{:deleted-at deleted-at}
{:id (:id prof)})
(let [team-before (th/db-get :team {:id team-id} {::db/remove-deleted false})]
(t/is (nil? (:deleted-at team-before))))
(let [result (th/run-task! :objects-gc {:min-age 0})]
(t/is (pos? (:processed result))))
(let [profile-after (th/db-get :profile {:id (:id prof)} {::db/remove-deleted false})]
(t/is (nil? profile-after)))
(let [team-after (th/db-get :team {:id team-id} {::db/remove-deleted false})]
(t/is (nil? team-after)))
(let [project-after (th/db-get :project {:id project-id} {::db/remove-deleted false})]
(t/is (nil? project-after)))
(let [file-after (th/db-get :file {:id file-id} {::db/remove-deleted false})]
(t/is (nil? file-after)))))
(t/deftest email-blacklist-1
(t/is (false? (email.blacklist/enabled? th/*system*)))

View File

@ -1328,3 +1328,83 @@
out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock)))))))
(t/deftest admin-cannot-remove-team-owner
(let [owner (th/create-profile* 1 {:is-active true})
admin (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id admin)
:role :admin})
(let [out (th/command! {::th/type :delete-team-member
::rpc/profile-id (:id admin)
:team-id (:id team)
:member-id (:id owner)})]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :validation))
(t/is (th/ex-of-code? (:error out) :cant-remove-owner)))))
(t/deftest owner-can-remove-another-owner
(let [owner1 (th/create-profile* 1 {:is-active true})
owner2 (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner1)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id owner2)
:role :owner})
(let [out (th/command! {::th/type :delete-team-member
::rpc/profile-id (:id owner1)
:team-id (:id team)
:member-id (:id owner2)})]
(t/is (th/success? out)))))
(t/deftest owner-can-remove-admin
(let [owner (th/create-profile* 1 {:is-active true})
admin (th/create-profile* 2 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id admin)
:role :admin})
(let [out (th/command! {::th/type :delete-team-member
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id (:id admin)})]
(t/is (th/success? out)))))
(t/deftest admin-can-remove-admin
(let [owner (th/create-profile* 1 {:is-active true})
admin1 (th/create-profile* 2 {:is-active true})
admin2 (th/create-profile* 3 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})]
(th/create-team-role* {:team-id (:id team)
:profile-id (:id admin1)
:role :admin})
(th/create-team-role* {:team-id (:id team)
:profile-id (:id admin2)
:role :admin})
(let [out (th/command! {::th/type :delete-team-member
::rpc/profile-id (:id admin1)
:team-id (:id team)
:member-id (:id admin2)})]
(t/is (th/success? out)))))
(t/deftest delete-nonexistent-member-returns-not-found
(let [owner (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id owner)})
fake-id (uuid/next)]
(let [out (th/command! {::th/type :delete-team-member
::rpc/profile-id (:id owner)
:team-id (:id team)
:member-id fake-id})]
(t/is (not (th/success? out)))
(t/is (th/ex-of-type? (:error out) :not-found))
(t/is (th/ex-of-code? (:error out) :member-does-not-exist)))))

View File

@ -128,3 +128,80 @@
(let [result (:result out)]
(t/is (contains? result :file))
(t/is (contains? result :project)))))))
(t/deftest share-link-token-disclosure
(let [owner (th/create-profile* 1 {:is-active true})
proj-id (:default-project-id owner)
file (th/create-file* 1 {:profile-id (:id owner)
:project-id proj-id
:is-shared false})
page-a (get-in file [:data :pages 0])
page-b (uuid/random)
;; Add a second page to the file
_ (th/command! {::th/type :update-file
::rpc/profile-id (:id owner)
:id (:id file)
:session-id (uuid/random)
:revn 0
:vern 0
:changes [{:type :add-page
:id page-b
:page {:id page-b
:name "Page B"
:options {}
:objects {}}}]})
;; Create Link A: restrictive (no pages, team-only comments/inspect)
link-a (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{}
:who-comment "team"
:who-inspect "team"})
link-a-id (get-in link-a [:result :id])
;; Create Link B: permissive (all pages, all can comment/inspect)
link-b (th/command! {::th/type :create-share-link
::rpc/profile-id (:id owner)
:file-id (:id file)
:pages #{page-a page-b}
:who-comment "all"
:who-inspect "all"})
link-b-id (get-in link-b [:result :id])]
(t/testing "restrictive share-link holder cannot see other share-link tokens"
(let [out (th/command! {::th/type :get-view-only-bundle
:share-id link-a-id
:file-id (:id file)})
err (:error out)
result (:result out)
share-links (:share-links result)]
;; Should not error
(t/is (nil? err))
;; Should only see the share-link used for authentication
(t/is (= 1 (count share-links)))
(t/is (= link-a-id (:id (first share-links))))
;; Should NOT see Link B's token
(t/is (not (some #(= link-b-id (:id %)) share-links)))))
(t/testing "team member still sees all share-links"
(let [out (th/command! {::th/type :get-view-only-bundle
::rpc/profile-id (:id owner)
:file-id (:id file)})
err (:error out)
result (:result out)
share-links (:share-links result)]
;; Should not error
(t/is (nil? err))
;; Team member should see both share-links
(t/is (= 2 (count share-links)))
(t/is (some #(= link-a-id (:id %)) share-links))
(t/is (some #(= link-b-id (:id %)) share-links))))))

View File

@ -80,6 +80,24 @@
(t/is (false? (ssrf/safe-url? "http://[fd00::1]/foo")))
(t/is (false? (ssrf/safe-url? "http://[fc00::1]/foo"))))
(t/deftest validate-url-blocks-nat64-encoded-metadata
;; 64:ff9b::a9fe:a9fe embeds 169.254.169.254 (cloud metadata)
(t/is (false? (ssrf/safe-url? "http://[64:ff9b::a9fe:a9fe]/latest/meta-data/"))))
(t/deftest validate-url-blocks-nat64-encoded-loopback
;; 64:ff9b::7f00:0001 embeds 127.0.0.1
(t/is (false? (ssrf/safe-url? "http://[64:ff9b::7f00:1]/foo"))))
(t/deftest validate-url-blocks-6to4-encoded-private
;; 2002:a00:1:: embeds 10.0.0.1; 2002:c0a8:101:: embeds 192.168.1.1
(t/is (false? (ssrf/safe-url? "http://[2002:a00:1::1]/foo")))
(t/is (false? (ssrf/safe-url? "http://[2002:c0a8:101::1]/foo"))))
(t/deftest validate-url-blocks-teredo-encoded-addresses
;; Teredo server prefix 2001:0000::/32
(t/is (false? (ssrf/safe-url?
"http://[2001:0000:4136:e378:8000:63bf:3fff:fdd2]/foo"))))
(t/deftest validate-url-blocks-encoded-loopback
;; Decimal encoding of 127.0.0.1 = 2130706433
;; InetAddress normalizes this to 127.0.0.1

View File

@ -17,14 +17,14 @@
org.slf4j/slf4j-api {:mvn/version "2.0.18"}
pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.43"}
selmer/selmer {:mvn/version "1.13.4"}
selmer/selmer {:mvn/version "1.13.5"}
criterium/criterium {:mvn/version "0.4.6"}
metosin/jsonista {:mvn/version "1.0.0"
:exclusions [com.fasterxml.jackson.core/jackson-core
com.fasterxml.jackson.core/jackson-databind]}
com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.1"}
com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.1"}
com.fasterxml.jackson.core/jackson-core {:mvn/version "2.22.2"}
com.fasterxml.jackson.core/jackson-databind {:mvn/version "2.22.2"}
metosin/malli {:mvn/version "0.20.1"}
@ -60,7 +60,7 @@
{:dev
{:extra-deps
{org.clojure/tools.namespace {:mvn/version "1.5.1"}
thheller/shadow-cljs {:mvn/version "3.4.11"}
thheller/shadow-cljs {:mvn/version "3.5.0"}
com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"}
com.bhauman/rebel-readline {:mvn/version "0.1.11"}
criterium/criterium {:mvn/version "0.4.6"}

View File

@ -4,18 +4,18 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
},
"devDependencies": {
"concurrently": "^10.0.4",
"concurrently": "^10.0.5",
"nodemon": "^3.1.14",
"prettier": "3.9.6",
"source-map-support": "^0.5.21",
"ws": "^8.21.2"
"ws": "^8.21.3"
},
"dependencies": {
"date-fns": "^4.4.0"

121
common/pnpm-lock.yaml generated
View File

@ -1,3 +1,104 @@
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.0.0
version: 12.0.0
packages:
'@pnpm/exe.darwin-arm64@12.0.0':
resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.0.0':
resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.linux-arm64-musl@12.0.0':
resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.0.0':
resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.0.0':
resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.0.0':
resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.0.0':
resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.0.0':
resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
cpu: [x64]
os: [win32]
pnpm@12.0.0:
resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/exe.darwin-arm64@12.0.0':
optional: true
'@pnpm/exe.darwin-x64@12.0.0':
optional: true
'@pnpm/exe.linux-arm64-musl@12.0.0':
optional: true
'@pnpm/exe.linux-arm64@12.0.0':
optional: true
'@pnpm/exe.linux-x64-musl@12.0.0':
optional: true
'@pnpm/exe.linux-x64@12.0.0':
optional: true
'@pnpm/exe.win32-arm64@12.0.0':
optional: true
'@pnpm/exe.win32-x64@12.0.0':
optional: true
pnpm@12.0.0:
optionalDependencies:
'@pnpm/exe.darwin-arm64': 12.0.0
'@pnpm/exe.darwin-x64': 12.0.0
'@pnpm/exe.linux-arm64': 12.0.0
'@pnpm/exe.linux-arm64-musl': 12.0.0
'@pnpm/exe.linux-x64': 12.0.0
'@pnpm/exe.linux-x64-musl': 12.0.0
'@pnpm/exe.win32-arm64': 12.0.0
'@pnpm/exe.win32-x64': 12.0.0
---
lockfileVersion: '9.0'
settings:
@ -13,8 +114,8 @@ importers:
version: 4.4.0
devDependencies:
concurrently:
specifier: ^10.0.4
version: 10.0.4
specifier: ^10.0.5
version: 10.0.5
nodemon:
specifier: ^3.1.14
version: 3.1.14
@ -25,8 +126,8 @@ importers:
specifier: ^0.5.21
version: 0.5.21
ws:
specifier: ^8.21.2
version: 8.21.2
specifier: ^8.21.3
version: 8.21.3
packages:
@ -73,8 +174,8 @@ packages:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
concurrently@10.0.4:
resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==}
concurrently@10.0.5:
resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==}
engines: {node: '>=22'}
hasBin: true
@ -234,8 +335,8 @@ packages:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
ws@8.21.2:
resolution: {integrity: sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==}
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@ -303,7 +404,7 @@ snapshots:
strip-ansi: 7.2.0
wrap-ansi: 9.0.2
concurrently@10.0.4:
concurrently@10.0.5:
dependencies:
chalk: 5.6.2
rxjs: 7.8.2
@ -439,7 +540,7 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.2.0
ws@8.21.2: {}
ws@8.21.3: {}
y18n@5.0.8: {}

View File

@ -66,7 +66,7 @@ RUN set -ex; \
FROM base AS setup-node
ENV NODE_VERSION=v24.19.0 \
ENV NODE_VERSION=v24.20.0 \
PATH=/opt/node/bin:$PATH
RUN set -eux; \

View File

@ -0,0 +1,10 @@
# Optional compose overlay, included by manage.sh's instance-compose ONLY
# when PENPOT_OPENCODE_CONFIG_DIR is set (run-devenv --opencode-config-dir
# DIR). Bind-mounts a host directory over the container's opencode global
# config dir (~/.config/opencode) so personal agents/prompts/skills kept in
# a separate repo are available inside the devenv without committing them
# here. Without the flag this file is never referenced.
services:
main:
volumes:
- "${PENPOT_OPENCODE_CONFIG_DIR}:/home/penpot/.config/opencode:z"

View File

@ -1,4 +1,4 @@
FROM dhi.io/node:24.19.0-debian13-dev
FROM dhi.io/node:24.20.0-debian13-dev
LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \

View File

@ -1,4 +1,4 @@
FROM dhi.io/node:24.18.1-debian13-dev AS build
FROM dhi.io/node:24.20.0-debian13-dev AS build
LABEL maintainer="Penpot <docker@penpot.app>"
ENV DEBIAN_FRONTEND=noninteractive

View File

@ -3,7 +3,7 @@ LABEL maintainer="Penpot <docker@penpot.app>"
ENV LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8 \
NODE_VERSION=v24.18.0 \
NODE_VERSION=v24.20.0 \
DEBIAN_FRONTEND=noninteractive \
PATH=/opt/node/bin:$PATH

View File

@ -29,7 +29,7 @@
"@11ty/eleventy-plugin-rss": "^3.0.0",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"@tigersway/eleventy-plugin-ancestry": "^1.0.3",
"@types/markdown-it": "14.1.2",
"@types/markdown-it": "14.2.0",
"elasticlunr": "^0.9.5",
"eleventy-plugin-metagen": "^1.8.4",
"eleventy-plugin-nesting-toc": "^1.3.0",
@ -39,5 +39,5 @@
"markdown-it-anchor": "^9.2.1",
"markdown-it-plantuml": "^1.4.1"
},
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee"
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
}

235
docs/pnpm-lock.yaml generated
View File

@ -1,3 +1,104 @@
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.0.0
version: 12.0.0
packages:
'@pnpm/exe.darwin-arm64@12.0.0':
resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.0.0':
resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.linux-arm64-musl@12.0.0':
resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.0.0':
resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.0.0':
resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.0.0':
resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.0.0':
resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.0.0':
resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
cpu: [x64]
os: [win32]
pnpm@12.0.0:
resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/exe.darwin-arm64@12.0.0':
optional: true
'@pnpm/exe.darwin-x64@12.0.0':
optional: true
'@pnpm/exe.linux-arm64-musl@12.0.0':
optional: true
'@pnpm/exe.linux-arm64@12.0.0':
optional: true
'@pnpm/exe.linux-x64-musl@12.0.0':
optional: true
'@pnpm/exe.linux-x64@12.0.0':
optional: true
'@pnpm/exe.win32-arm64@12.0.0':
optional: true
'@pnpm/exe.win32-x64@12.0.0':
optional: true
pnpm@12.0.0:
optionalDependencies:
'@pnpm/exe.darwin-arm64': 12.0.0
'@pnpm/exe.darwin-x64': 12.0.0
'@pnpm/exe.linux-arm64': 12.0.0
'@pnpm/exe.linux-arm64-musl': 12.0.0
'@pnpm/exe.linux-x64': 12.0.0
'@pnpm/exe.linux-x64-musl': 12.0.0
'@pnpm/exe.win32-arm64': 12.0.0
'@pnpm/exe.win32-x64': 12.0.0
---
lockfileVersion: '9.0'
settings:
@ -24,8 +125,8 @@ importers:
specifier: ^1.0.3
version: 1.0.3(@11ty/eleventy@3.1.6)
'@types/markdown-it':
specifier: 14.1.2
version: 14.1.2
specifier: 14.2.0
version: 14.2.0
elasticlunr:
specifier: ^0.9.5
version: 0.9.5
@ -46,7 +147,7 @@ importers:
version: 15.0.0
markdown-it-anchor:
specifier: ^9.2.1
version: 9.2.1(@types/markdown-it@14.1.2)(markdown-it@15.0.0)
version: 9.2.1(@types/markdown-it@14.2.0)(markdown-it@15.0.0)
markdown-it-plantuml:
specifier: ^1.4.1
version: 1.4.1
@ -64,8 +165,8 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@11ty/eleventy-fetch@5.1.2':
resolution: {integrity: sha512-YxDARdR3S9UT4gOGRWgGNyokYT9jkCAjJge3OVKFdNMv1cyvWg1NHCvj9NVvK9XHenCLFy3cwvM/YYpZZTZopw==}
'@11ty/eleventy-fetch@5.1.3':
resolution: {integrity: sha512-4HS6QB/mVWTVlE6kjCKPkjkC1Z0YOqizJaHR05zK+E7a2b4EUC3T+wLktIy7wEl76ZoarkY45EKLmTw1XuR8fQ==}
engines: {node: '>=18'}
'@11ty/eleventy-navigation@1.0.5':
@ -102,8 +203,8 @@ packages:
resolution: {integrity: sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==}
engines: {node: '>=18'}
'@rgrove/parse-xml@4.2.0':
resolution: {integrity: sha512-UuBOt7BOsKVOkFXRe4Ypd/lADuNIfqJXv8GvHqtXaTYXPPKkj2nS2zPllVsrtRjcomDhIJVBnZwfmlI222WH8g==}
'@rgrove/parse-xml@4.2.3':
resolution: {integrity: sha512-Jhlb+0zYez1T1yXUQs3F1qAtFuJljBVNdy9TKmLDauAXkxsOXopKYOyQ5Wm6SvP3fycav0GviX4Y15WWhGetMw==}
engines: {node: '>=14.0.0'}
'@sindresorhus/slugify@2.2.1':
@ -122,8 +223,8 @@ packages:
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/markdown-it@14.2.0':
resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
@ -135,8 +236,8 @@ packages:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
acorn@8.17.0:
resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==}
acorn@8.18.0:
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
engines: {node: '>=0.4.0'}
hasBin: true
@ -150,8 +251,8 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
argparse@3.0.0:
resolution: {integrity: sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==}
argparse@3.0.1:
resolution: {integrity: sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ==}
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
@ -165,8 +266,8 @@ packages:
bcp-47-normalize@2.3.0:
resolution: {integrity: sha512-8I/wfzqQvttUFz7HVJgIZ7+dj3vUaIyIxYXaTRP1YWoSDfzt6TUmxaKZeuXR62qBmYr+nvuWINFRl6pZ5DlN4Q==}
bcp-47@2.1.0:
resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==}
bcp-47@2.1.1:
resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
@ -363,8 +464,8 @@ packages:
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
engines: {node: '>= 0.8'}
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
flatted@3.4.4:
resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
@ -439,12 +540,12 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
iso-639-1@3.1.5:
resolution: {integrity: sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==}
iso-639-1@3.1.6:
resolution: {integrity: sha512-ZFar/L4ngX7wZh2QX+Fiftmuf0igWJsrJtfizrovWifF1gAWkfmRa5Z1m0LQZbm0hKCHRDYhLRSLFrSqNe4EJA==}
engines: {node: '>=6.0'}
js-yaml@3.15.0:
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
js-yaml@3.15.1:
resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==}
hasBin: true
js-yaml@4.3.1:
@ -469,8 +570,8 @@ packages:
linkify-it@6.1.0:
resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==}
liquidjs@10.28.0:
resolution: {integrity: sha512-b6tmBXYMQTuGPnM5vB0CuZMo5kvmKMtSB/gvUWP6RFn2pIB5s+bJkBfIyJnattkc5bgeAiflrZVptx3iaLLioQ==}
liquidjs@10.29.0:
resolution: {integrity: sha512-pCVOhs6FLAR8su3ItJ07diN26t6W5dHQRnmTMy8HPyTFuv1+oSCVJIGp5pGjfQyOZfh50KswvKtMTp6p4JEIdw==}
engines: {node: '>=16'}
hasBin: true
@ -598,8 +699,8 @@ packages:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
please-upgrade-node@3.2.0:
@ -634,8 +735,8 @@ packages:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
range-parser@1.3.0:
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
engines: {node: '>= 0.6'}
readdirp@3.6.0:
@ -652,8 +753,8 @@ packages:
semver-compare@1.0.0:
resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
semver@7.8.4:
resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
@ -729,8 +830,8 @@ packages:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@ -746,7 +847,7 @@ snapshots:
'@11ty/dependency-tree-esm@2.0.4':
dependencies:
'@11ty/eleventy-utils': 2.0.7
acorn: 8.17.0
acorn: 8.18.0
dependency-graph: 1.0.0
normalize-path: 3.0.0
@ -767,18 +868,18 @@ snapshots:
send: 1.2.1
ssri: 11.0.0
urlpattern-polyfill: 10.1.0
ws: 8.21.0
ws: 8.21.3
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
'@11ty/eleventy-fetch@5.1.2':
'@11ty/eleventy-fetch@5.1.3':
dependencies:
'@11ty/eleventy-utils': 2.0.7
'@rgrove/parse-xml': 4.2.0
'@rgrove/parse-xml': 4.2.3
debug: 4.4.3
flatted: 3.4.2
flatted: 3.4.4
p-queue: 6.6.2
transitivePeerDependencies:
- supports-color
@ -829,21 +930,21 @@ snapshots:
entities: 6.0.1
filesize: 10.1.6
gray-matter: 4.0.3
iso-639-1: 3.1.5
iso-639-1: 3.1.6
js-yaml: 4.3.1
kleur: 4.1.5
liquidjs: 10.28.0
liquidjs: 10.29.0
luxon: 3.7.2
markdown-it: 14.3.0
minimist: 1.2.8
moo: 0.5.2
node-retrieve-globals: 6.0.1
nunjucks: 3.2.4(chokidar@3.6.0)
picomatch: 4.0.4
picomatch: 4.0.7
please-upgrade-node: 3.2.0
posthtml: 0.16.7
posthtml-match-helper: 2.0.3(posthtml@0.16.7)
semver: 7.8.4
semver: 7.8.5
slugify: 1.6.9
tinyglobby: 0.2.17
transitivePeerDependencies:
@ -867,7 +968,7 @@ snapshots:
minimatch: 3.1.5
slash: 3.0.0
'@rgrove/parse-xml@4.2.0': {}
'@rgrove/parse-xml@4.2.3': {}
'@sindresorhus/slugify@2.2.1':
dependencies:
@ -884,7 +985,7 @@ snapshots:
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2':
'@types/markdown-it@14.2.0':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
@ -895,9 +996,9 @@ snapshots:
acorn-walk@8.3.5:
dependencies:
acorn: 8.17.0
acorn: 8.18.0
acorn@8.17.0: {}
acorn@8.18.0: {}
anymatch@3.1.3:
dependencies:
@ -910,7 +1011,7 @@ snapshots:
argparse@2.0.1: {}
argparse@3.0.0: {}
argparse@3.0.1: {}
asap@2.0.6: {}
@ -920,10 +1021,10 @@ snapshots:
bcp-47-normalize@2.3.0:
dependencies:
bcp-47: 2.1.0
bcp-47: 2.1.1
bcp-47-match: 2.0.3
bcp-47@2.1.0:
bcp-47@2.1.1:
dependencies:
is-alphabetical: 2.0.1
is-alphanumerical: 2.0.1
@ -1060,7 +1161,7 @@ snapshots:
eleventy-plugin-youtube-embed@1.13.2:
dependencies:
'@11ty/eleventy-fetch': 5.1.2
'@11ty/eleventy-fetch': 5.1.3
deepmerge: 4.3.1
lite-youtube-embed: 0.3.4
string-replace-async: 3.0.2
@ -1096,7 +1197,7 @@ snapshots:
esm-import-transformer@3.0.5:
dependencies:
acorn: 8.17.0
acorn: 8.18.0
esprima@4.0.1: {}
@ -1110,9 +1211,9 @@ snapshots:
dependencies:
is-extendable: 0.1.1
fdir@6.5.0(picomatch@4.0.4):
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.4
picomatch: 4.0.7
filesize@10.1.6: {}
@ -1132,7 +1233,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
flatted@3.4.2: {}
flatted@3.4.4: {}
fresh@2.0.0: {}
@ -1147,7 +1248,7 @@ snapshots:
gray-matter@4.0.3:
dependencies:
js-yaml: 3.15.0
js-yaml: 3.15.1
kind-of: 6.0.3
section-matter: 1.0.0
strip-bom-string: 1.0.0
@ -1207,9 +1308,9 @@ snapshots:
is-number@7.0.0: {}
iso-639-1@3.1.5: {}
iso-639-1@3.1.6: {}
js-yaml@3.15.0:
js-yaml@3.15.1:
dependencies:
argparse: 1.0.10
esprima: 4.0.1
@ -1232,7 +1333,7 @@ snapshots:
dependencies:
uc.micro: 3.0.0
liquidjs@10.28.0:
liquidjs@10.29.0:
dependencies:
commander: 10.0.1
@ -1242,9 +1343,9 @@ snapshots:
luxon@3.7.2: {}
markdown-it-anchor@9.2.1(@types/markdown-it@14.1.2)(markdown-it@15.0.0):
markdown-it-anchor@9.2.1(@types/markdown-it@14.2.0)(markdown-it@15.0.0):
dependencies:
'@types/markdown-it': 14.1.2
'@types/markdown-it': 14.2.0
markdown-it: 15.0.0
markdown-it-plantuml@1.4.1: {}
@ -1260,7 +1361,7 @@ snapshots:
markdown-it@15.0.0:
dependencies:
argparse: 3.0.0
argparse: 3.0.1
entities: 8.0.0
linkify-it: 6.1.0
mdurl: 2.1.0
@ -1299,7 +1400,7 @@ snapshots:
node-retrieve-globals@6.0.1:
dependencies:
acorn: 8.17.0
acorn: 8.18.0
acorn-walk: 8.3.5
esm-import-transformer: 3.0.5
@ -1351,7 +1452,7 @@ snapshots:
picomatch@2.3.2: {}
picomatch@4.0.4: {}
picomatch@4.0.7: {}
please-upgrade-node@3.2.0:
dependencies:
@ -1380,7 +1481,7 @@ snapshots:
punycode.js@2.3.1: {}
range-parser@1.2.1: {}
range-parser@1.3.0: {}
readdirp@3.6.0:
dependencies:
@ -1395,7 +1496,7 @@ snapshots:
semver-compare@1.0.0: {}
semver@7.8.4: {}
semver@7.8.5: {}
send@1.2.1:
dependencies:
@ -1408,7 +1509,7 @@ snapshots:
mime-types: 3.0.2
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
range-parser: 1.3.0
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
@ -1433,8 +1534,8 @@ snapshots:
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
to-regex-range@5.0.1:
dependencies:
@ -1458,4 +1559,4 @@ snapshots:
whatwg-mimetype@4.0.0: {}
ws@8.21.0: {}
ws@8.21.3: {}

View File

@ -148,10 +148,15 @@ automatically, so regular users never run this.
```bash
./manage.sh run-devenv --agentic \
[--ws N] [--sync] [--serena-context CTX] \
[--opencode-config-dir DIR] \
[--git-user-name NAME] [--git-user-email EMAIL]
```
Brings one agentic instance up. Errors out if the target is already running.
`--opencode-config-dir DIR` bind-mounts DIR over the container's
`~/.config/opencode` so personal agents/prompts/skills kept in a separate
repository are available to the coding agent; see the
[Dev environment guide](./devenv.md#personal-opencode-config-inside-the-container).
`--ws N` (N ≥ 1) brings that workspace up independently — workspaces can be
started and stopped in any order. Per-instance ports

View File

@ -139,6 +139,27 @@ until you set an identity. The values are applied every time
`run-devenv` brings an instance up (idempotent), so re-running
with different flags is the way to change the in-container identity.
### Personal opencode config inside the container
`run-devenv --opencode-config-dir DIR` bind-mounts a host directory over the
container's `~/.config/opencode` (opencode's global config dir). This is how
you keep personal agents, prompts, and skills in a separate repository and
use them inside the devenv without committing them here or leaving untracked
files in the repo:
```bash
./manage.sh run-devenv --agentic --opencode-config-dir ../penpot-opencode
```
The path must be an existing directory; `~` is expanded and the value is
resolved to an absolute path automatically. The mount is applied at container
creation, so changing it requires stopping and re-running `run-devenv` for
that instance, and it applies only to instances brought up with the flag —
other workspaces mount nothing. The directory is shared read-write with the
container (same UID mapping as the source tree). Opencode's own state
(sessions, `auth.json`) lives in `~/.local/share/opencode`, which stays in
the container's data volume regardless of this flag.
### Shared state and workers
All instances share one Penpot database and one MinIO bucket; users, teams,

View File

@ -14,7 +14,7 @@
:dev
{:extra-deps
{thheller/shadow-cljs {:mvn/version "3.4.11"}}}
{thheller/shadow-cljs {:mvn/version "3.5.0"}}}
:shadow-cljs
{:main-opts ["-m" "shadow.cljs.devtools.cli"]

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
@ -21,12 +21,12 @@
"playwright": "1.62.1",
"raw-body": "^4.0.0",
"source-map-support": "^0.5.21",
"undici": "^8.9.0",
"undici": "^8.10.0",
"xml-js": "^1.6.11",
"xregexp": "^5.1.2"
},
"devDependencies": {
"ws": "^8.21.1"
"ws": "^8.21.3"
},
"scripts": {
"clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target",

121
exporter/pnpm-lock.yaml generated
View File

@ -1,3 +1,104 @@
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.0.0
version: 12.0.0
packages:
'@pnpm/exe.darwin-arm64@12.0.0':
resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.0.0':
resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.linux-arm64-musl@12.0.0':
resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.0.0':
resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.0.0':
resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.0.0':
resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.0.0':
resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.0.0':
resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
cpu: [x64]
os: [win32]
pnpm@12.0.0:
resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/exe.darwin-arm64@12.0.0':
optional: true
'@pnpm/exe.darwin-x64@12.0.0':
optional: true
'@pnpm/exe.linux-arm64-musl@12.0.0':
optional: true
'@pnpm/exe.linux-arm64@12.0.0':
optional: true
'@pnpm/exe.linux-x64-musl@12.0.0':
optional: true
'@pnpm/exe.linux-x64@12.0.0':
optional: true
'@pnpm/exe.win32-arm64@12.0.0':
optional: true
'@pnpm/exe.win32-x64@12.0.0':
optional: true
pnpm@12.0.0:
optionalDependencies:
'@pnpm/exe.darwin-arm64': 12.0.0
'@pnpm/exe.darwin-x64': 12.0.0
'@pnpm/exe.linux-arm64': 12.0.0
'@pnpm/exe.linux-arm64-musl': 12.0.0
'@pnpm/exe.linux-x64': 12.0.0
'@pnpm/exe.linux-x64-musl': 12.0.0
'@pnpm/exe.win32-arm64': 12.0.0
'@pnpm/exe.win32-x64': 12.0.0
---
lockfileVersion: '9.0'
settings:
@ -45,8 +146,8 @@ importers:
specifier: ^0.5.21
version: 0.5.21
undici:
specifier: ^8.9.0
version: 8.9.0
specifier: ^8.10.0
version: 8.10.0
xml-js:
specifier: ^1.6.11
version: 1.6.11
@ -55,8 +156,8 @@ importers:
version: 5.1.2
devDependencies:
ws:
specifier: ^8.21.1
version: 8.21.1
specifier: ^8.21.3
version: 8.21.3
packages:
@ -420,15 +521,15 @@ packages:
resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==}
engines: {node: '>=0.6.x'}
undici@8.9.0:
resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==}
undici@8.10.0:
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
engines: {node: '>=22.19.0'}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
ws@8.21.1:
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@ -802,11 +903,11 @@ snapshots:
tsscmp@1.0.6: {}
undici@8.9.0: {}
undici@8.10.0: {}
util-deprecate@1.0.2: {}
ws@8.21.1: {}
ws@8.21.3: {}
xml-js@1.6.11:
dependencies:

View File

@ -4,7 +4,7 @@
penpot/common
{:local/root "../common"}
org.clojure/clojure {:mvn/version "1.12.2"}
org.clojure/clojure {:mvn/version "1.12.5"}
binaryage/devtools {:mvn/version "RELEASE"}
metosin/reitit-core {:mvn/version "0.10.1"}
funcool/okulary {:mvn/version "2022.04.11-16"}
@ -51,7 +51,7 @@
"--enable-native-access=ALL-UNNAMED"]
:extra-deps
{thheller/shadow-cljs {:mvn/version "3.4.11"}
{thheller/shadow-cljs {:mvn/version "3.5.0"}
com.bhauman/rebel-readline {:mvn/version "RELEASE"}
org.clojure/tools.namespace {:mvn/version "RELEASE"}
criterium/criterium {:mvn/version "0.4.6"}}}

View File

@ -4,7 +4,7 @@
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"private": true,
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"browserslist": [
"defaults"
],
@ -59,33 +59,33 @@
"@penpot/tokenscript": "link:packages/tokenscript",
"@penpot/ua-parser": "penpot/ua-parser#1.0.0",
"@playwright/test": "1.62.1",
"@storybook/addon-docs": "10.5.6",
"@storybook/addon-themes": "10.5.6",
"@storybook/addon-vitest": "10.5.6",
"@storybook/react-vite": "10.5.6",
"@storybook/addon-docs": "10.5.10",
"@storybook/addon-themes": "10.5.10",
"@storybook/addon-vitest": "10.5.10",
"@storybook/react-vite": "10.5.10",
"@tokens-studio/sd-transforms": "2.0.3",
"@types/node": "^26.1.2",
"@vitest/browser": "4.1.10",
"@vitest/browser-playwright": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@types/node": "^26.4.0",
"@vitest/browser": "4.1.11",
"@vitest/browser-playwright": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@zip.js/zip.js": "2.8.34",
"autoprefixer": "^10.5.4",
"compression": "^1.8.1",
"concurrently": "^10.0.4",
"concurrently": "^10.0.5",
"date-fns": "^4.4.0",
"esbuild": "^0.28.1",
"eventsource-parser": "^3.1.0",
"esbuild": "^0.28.2",
"eventsource-parser": "^4.1.0",
"express": "^5.1.0",
"fancy-log": "^2.0.0",
"getopts": "^2.3.0",
"gettext-parser": "^9.1.1",
"highlight.js": "^11.10.0",
"highlight.js": "^11.12.0",
"js-beautify": "^2.0.3",
"jsdom": "^30.0.1",
"lodash": "^4.18.1",
"lodash.debounce": "^4.0.8",
"map-stream": "0.0.7",
"marked": "^18.0.9",
"marked": "^18.0.11",
"mkdirp": "^3.0.1",
"mustache": "^4.2.0",
"nodemon": "^3.1.14",
@ -93,7 +93,7 @@
"opentype.js": "^2.0.0",
"p-limit": "^7.3.1",
"playwright": "1.62.1",
"postcss": "^8.5.25",
"postcss": "^8.5.26",
"postcss-clean": "^1.2.2",
"postcss-modules": "^9.0.1",
"postcss-scss": "^4.0.9",
@ -103,27 +103,27 @@
"randomcolor": "^0.6.2",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-error-boundary": "^6.1.2",
"react-error-boundary": "^6.1.3",
"react-virtualized": "^9.22.6",
"rimraf": "^6.1.3",
"rxjs": "8.0.0-alpha.14",
"sass": "^1.102.0",
"sass-embedded": "^1.100.0",
"sass": "^1.103.1",
"sass-embedded": "^1.103.1",
"sax": "^1.6.1",
"scheduler": "^0.27.0",
"source-map-support": "^0.5.21",
"storybook": "10.5.6",
"style-dictionary": "5.5.0",
"storybook": "10.5.10",
"style-dictionary": "5.5.2",
"stylelint": "^17.14.1",
"stylelint-config-standard-scss": "^17.0.0",
"stylelint-plugin-logical-css": "^2.1.0",
"stylelint-scss": "^7.2.0",
"svg-sprite": "^2.0.4",
"tdigest": "^0.1.2",
"tdigest": "^0.1.3",
"tinycolor2": "^1.6.0",
"typescript": "^6.0.2",
"vite": "^8.2.0",
"vitest": "^4.1.10",
"vite": "^8.2.2",
"vitest": "^4.1.11",
"wait-on": "^9.1.0",
"watcher": "^2.3.1",
"workerpool": "^10.0.3",

View File

@ -4,7 +4,7 @@
"description": "Penpot Draft-JS Wrapper",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {

View File

@ -4,7 +4,7 @@
"description": "Simple library for handling keyboard shortcuts",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"author": "Craig Campbell",
"license": "Apache-2.0 WITH LLVM-exception"
}

View File

@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"type": "module",
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"author": "Andrey Antukh",
"license": "MPL-2.0",
"dependencies": {

View File

@ -20,20 +20,20 @@
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/preset-react": "^8.0.1",
"@storybook/react": "10.5.6",
"@storybook/react-vite": "10.5.6",
"@storybook/react": "10.5.10",
"@storybook/react-vite": "10.5.10",
"@testing-library/dom": "10.4.1",
"@testing-library/react": "16.3.2",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"babel-plugin-react-compiler": "^1.0.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"react-compiler-runtime": "^1.0.0",
"storybook": "10.5.6",
"storybook": "10.5.10",
"vite-plugin-dts": "^5.0.3"
},
"dependencies": {

1608
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@
[app.main.ui.auth.login :refer [login-page*]]
[app.main.ui.auth.recovery :refer [recovery-page*]]
[app.main.ui.auth.recovery-request :refer [recovery-request-page*]]
[app.main.ui.auth.register :refer [register-page* register-success-page* register-validate-page* terms-register*]]
[app.main.ui.auth.register :refer [register-page* register-success-page* register-validate-page* terms-service-privacy-policy*]]
[app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*] :as raw-svg]
[app.main.ui.ds.foundations.typography.heading :refer [heading*]]
[app.util.dom :as dom]
@ -74,7 +74,7 @@
[:> recovery-page* {:params params}])
(when (= section :auth-register)
[:> terms-register*])]]))
[:> terms-service-privacy-policy*])]]))
(mf/defc auth-page*

View File

@ -6,6 +6,7 @@
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "./ds/mixins.scss" as *;
.auth-section {
display: grid;
@ -26,6 +27,8 @@
}
.auth-section.register {
@include custom-scrollbar;
display: flex;
justify-content: center;
align-items: center;

View File

@ -4,56 +4,15 @@
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "ds/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/typography.scss" as *;
.auth-form-wrapper {
inline-size: 100%;
padding-block-end: 0;
display: grid;
.form {
display: flex;
flex-direction: column;
gap: var(--sp-m);
// Native <form> inside auth-form-wrapper no class available
form {
display: flex;
flex-direction: column;
gap: var(--sp-m);
margin-block-start: var(--sp-m);
}
}
.auth-title-wrapper {
inline-size: 100%;
padding-block-end: 0;
display: grid;
gap: var(--sp-s);
}
.separator {
border-color: var(--color-background-quaternary);
margin: 0;
}
.auth-title {
@include use-typography("title-large");
line-height: 1.2;
color: var(--color-foreground-primary);
}
.auth-subtitle {
@include use-typography("title-small");
color: var(--color-foreground-secondary);
}
.auth-tagline {
@include use-typography("title-small");
margin: 0;
color: var(--color-foreground-secondary);
margin: var(--sp-m) 0;
}
.form-field {
@ -62,36 +21,40 @@
--input-min-width: 100%;
}
.buttons-stack {
display: grid;
gap: var(--sp-s);
}
.form-submit-btn {
--button-bg-color: var(--color-accent-primary);
--button-border-color: var(--color-accent-primary);
--button-fg-color: var(--color-background-secondary);
.login-button,
.login-ldap-button {
@include use-typography("headline-small");
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
background-color: var(--color-accent-primary);
border: $b-1 solid var(--color-accent-primary);
color: var(--color-background-secondary);
background-color: var(--button-bg-color);
border: $b-1 solid var(--button-border-color);
color: var(--button-fg-color);
border-radius: $br-8;
min-block-size: $sz-32;
block-size: $sz-40;
inline-size: 100%;
&:hover {
--button-bg-color: var(--color-accent-tertiary);
--button-border-color: var(--color-accent-tertiary);
--button-fg-color: var(--color-background-secondary);
text-decoration: none;
}
&:disabled {
background-color: var(--color-background-quaternary);
border: 1px solid var(--color-background-quaternary);
color: var(--color-foreground-disabled);
cursor: unset;
--button-bg-color: var(--color-background-quaternary);
--button-border-color: var(--color-background-quaternary);
--button-fg-color: var(--color-foreground-disabled);
}
}
.go-back {
.go-back-row {
display: flex;
flex-direction: column;
gap: var(--sp-m);
@ -100,19 +63,68 @@
}
.go-back-link {
--button-bg-color: var(--color-background-tertiary);
--button-border-color: var(--color-background-tertiary);
--button-fg-color: var(--color-foreground-secondary);
@include use-typography("headline-small");
background: none;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
border-radius: $br-8;
background-color: var(--color-background-tertiary);
border: $b-1 solid var(--color-background-tertiary);
color: var(--color-foreground-secondary);
@include use-typography("headline-small");
background-color: var(--button-bg-color);
border: $b-1 solid var(--button-border-color);
color: var(--button-fg-color);
block-size: $sz-40;
&:hover {
--button-bg-color: var(--color-background-quaternary);
--button-border-color: var(--color-background-quaternary);
--button-fg-color: var(--color-accent-primary);
text-decoration: none;
}
}
.separator {
border-color: var(--color-background-quaternary);
margin: 0;
}
.wrapper {
inline-size: 100%;
padding-block-end: 0;
display: grid;
gap: var(--sp-m);
}
.title-wrapper {
inline-size: 100%;
padding-block-end: 0;
display: grid;
gap: var(--sp-s);
}
.title {
@include use-typography("title-large");
line-height: 1.2;
color: var(--color-foreground-primary);
}
.subtitle {
@include use-typography("title-small");
color: var(--color-foreground-secondary);
}
.tagline {
@include use-typography("title-small");
margin: 0;
color: var(--color-foreground-secondary);
}
.links {
@ -120,104 +132,10 @@
gap: var(--sp-xxl);
}
.register,
.account,
.recovery-request,
.demo-account {
display: flex;
justify-content: center;
gap: var(--sp-s);
padding: 0;
}
.notification-email {
@include use-typography("title-medium");
.register-text,
.account-text,
.recovery-text,
.demo-account-text {
@include use-typography("title-small");
text-align: right;
color: var(--color-foreground-secondary);
}
.register-link,
.account-link,
.recovery-link,
.forgot-pass-link,
.demo-account-link {
@include use-typography("title-small");
text-align: left;
background-color: transparent;
border: none;
display: inline;
line-height: 1.2;
color: var(--color-accent-primary);
&:hover {
text-decoration: underline;
}
}
.forgot-password {
display: flex;
justify-content: flex-end;
}
.submit-btn,
.register-btn,
.recover-btn {
@include use-typography("headline-small");
background: none;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--color-accent-primary);
border: $b-1 solid var(--color-accent-primary);
color: var(--color-background-secondary);
border-radius: $br-8;
min-block-size: $sz-32;
block-size: $sz-40;
inline-size: 100%;
&:disabled {
background-color: var(--color-background-quaternary);
border: $b-1 solid var(--color-background-quaternary);
color: var(--color-foreground-disabled);
cursor: unset;
}
}
.login-btn {
@include use-typography("title-small");
display: flex;
align-items: center;
gap: px2rem(6);
inline-size: 100%;
border-radius: $br-8;
background-color: var(--color-background-tertiary);
color: var(--color-foreground-primary);
span {
padding-block-start: var(--sp-xxs);
}
&:hover {
color: var(--color-foreground-primary);
background-color: var(--color-background-quaternary);
}
&:disabled {
background-color: var(--color-background-quaternary);
border: 1px solid var(--color-background-quaternary);
color: var(--color-foreground-disabled);
cursor: unset;
}
}
.auth-buttons {
display: flex;
gap: var(--sp-s);
margin-inline: $sz-36;
}

View File

@ -71,10 +71,17 @@
(mf/defc login-form*
[{:keys [params handle-redirect on-success-callback on-recovery-request origin] :as props}]
(let [initial (mf/with-memo [params] params)
error (mf/use-state false)
form (fm/use-form :schema schema:login-form
:initial initial)
error (mf/use-state false)
show-password-field* (mf/use-state #(not (contains? cf/flags :login-with-custom-sso)))
show-password-field? (deref show-password-field*)
callback-url (:callback-url params)
on-error
(fn [cause]
(let [cause (ex-data cause)]
@ -102,12 +109,6 @@
:else
(reset! error (tr "errors.generic")))))
show-password-field*
(mf/use-state #(not (contains? cf/flags :login-with-custom-sso)))
show-password-field?
(deref show-password-field*)
on-success
(fn [data]
(when (fn? on-success-callback)
@ -130,7 +131,8 @@
(->> (rp/cmd! :get-sso-provider {:email (:email params)})
(rx/map :id)
(rx/catch (fn [cause]
(log/error :hint "error on retrieving sso provider" :cause cause)
(log/error :hint "error on retrieving sso provider"
:cause cause)
(rx/of nil)))
(rx/subs! (fn [sso-provider-id]
(if sso-provider-id
@ -139,7 +141,7 @@
(reset! show-password-field* true))))))))))
on-submit-ldap
(mf/use-callback
(mf/use-fn
(mf/deps form)
(fn [event]
(dom/prevent-default event)
@ -168,91 +170,102 @@
{:level :error} message])
[:& fm/form {:on-submit on-submit
:class (stl/css :login-form)
:class (stl/css :form)
:form form}
[:div {:class (stl/css :fields-row)}
[:& fm/input
{:name :email
:type "email"
:label (tr "auth.work-email")
:class (stl/css :form-field)}]]
[:div {:class (stl/css :form-row)}
[:& fm/input {:name :email
:type "email"
:label (tr "auth.work-email")
:class (stl/css :form-field)}]]
(when show-password-field?
[:div {:class (stl/css :fields-row)}
[:& fm/input
{:type "password"
:name :password
:auto-focus? true
:label (tr "auth.password")
:class (stl/css :form-field)}]])
[:div {:class (stl/css :form-row)}
[:& fm/input {:type "password"
:name :password
:auto-focus? true
:label (tr "auth.password")
:class (stl/css :form-field)}]])
(when (and (not= origin :viewer)
(or (contains? cf/flags :login)
(contains? cf/flags :login-with-password)))
[:div {:class (stl/css :fields-row :forgot-password)}
[:div {:class (stl/css :form-row :forgot-password-row)}
[:> lk/link* {:action on-recovery-request
:class (stl/css :forgot-pass-link)
:class (stl/css :forgot-password-link)
:data-testid "forgot-password"}
(tr "auth.forgot-password")]])
[:div {:class (stl/css :buttons-stack)}
[:div {:class (stl/css :form-submit-buttons)}
(when (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password))
[:> fm/submit-button*
{:label (tr "labels.continue")
:data-testid "login-submit"
:class (stl/css :login-button)}])
[:> fm/submit-button* {:label (tr "labels.continue")
:data-testid "login-submit"
:class (stl/css :form-submit-btn)}])
(when (contains? cf/flags :login-with-ldap)
[:> fm/submit-button*
{:label (tr "auth.login-with-ldap-submit")
:class (stl/css :login-ldap-button)
:on-click on-submit-ldap}])]]]))
[:> fm/submit-button* {:label (tr "auth.login-with-ldap-submit")
:class (stl/css :form-submit-btn)
:on-click on-submit-ldap}])]]]))
(defn raw-icon
[id]
(mf/html
[:> raw-svg* {:id id :class (stl/css :sso-icon)}]))
(mf/defc login-sso-buttons*
(mf/defc sso-buttons*
[{:keys [params] :as props}]
(let [login-with-google (mf/use-fn (mf/deps params) #(login-with-sso "google" params))
login-with-github (mf/use-fn (mf/deps params) #(login-with-sso "github" params))
login-with-gitlab (mf/use-fn (mf/deps params) #(login-with-sso "gitlab" params))
login-with-oidc (mf/use-fn (mf/deps params) #(login-with-sso "oidc" params))]
(let [login-with-google
(mf/use-fn
(mf/deps params)
#(login-with-sso "google" params))
[:div {:class (stl/css :auth-buttons)}
login-with-github
(mf/use-fn
(mf/deps params)
#(login-with-sso "github" params))
login-with-gitlab
(mf/use-fn
(mf/deps params)
#(login-with-sso "gitlab" params))
login-with-oidc
(mf/use-fn
(mf/deps params)
#(login-with-sso "oidc" params))]
[:div {:class (stl/css :sso-row)}
(when (contains? cf/flags :login-with-google)
[:> bl/button-link* {:on-click login-with-google
:icon (raw-icon raw-icons/brand-google)
:label (tr "auth.login-with-google-submit")
:class (stl/css :login-btn :btn-google-auth)}])
:class (stl/css :sso-btn)}])
(when (contains? cf/flags :login-with-github)
[:> bl/button-link* {:on-click login-with-github
:icon (raw-icon raw-icons/brand-github)
:label (tr "auth.login-with-github-submit")
:class (stl/css :login-btn :btn-github-auth)}])
:class (stl/css :sso-btn)}])
(when (contains? cf/flags :login-with-gitlab)
[:> bl/button-link* {:on-click login-with-gitlab
:icon (raw-icon raw-icons/brand-gitlab)
:label (tr "auth.login-with-gitlab-submit")
:class (stl/css :login-btn :btn-gitlab-auth)}])
:class (stl/css :sso-btn)}])
(when (contains? cf/flags :login-with-oidc)
[:> bl/button-link* {:on-click login-with-oidc
:icon (raw-icon raw-icons/brand-openid)
:label (or (not-empty cf/oidc-name) (tr "auth.login-with-oidc-submit"))
:class (stl/css :login-btn :btn-oidc-auth)}])]))
:class (stl/css :sso-btn)}])]))
(mf/defc login-dialog*
[{:keys [params] :as props}]
[:*
(when show-sso-login-buttons?
[:*
[:> login-sso-buttons* {:params params}]
[:> sso-buttons* {:params params}]
(when (or (contains? cf/flags :login)
(contains? cf/flags :login-with-password)
@ -270,11 +283,11 @@
(mf/use-fn
#(st/emit! (rt/nav :auth-register params)))]
[:div {:class (stl/css :auth-form-wrapper)}
[:h1 {:class (stl/css :auth-title)
[:div {:class (stl/css :wrapper)}
[:h1 {:class (stl/css :title)
:data-testid "login-title"} (tr "auth.login-account-title")]
[:p {:class (stl/css :auth-tagline)}
[:p {:class (stl/css :tagline)}
(tr "auth.login-tagline")]
(when (contains? cf/flags :demo-warning)
@ -286,7 +299,7 @@
[:div {:class (stl/css :links)}
(when (contains? cf/flags :registration)
[:div {:class (stl/css :register)}
[:div {:class (stl/css :register-row)}
[:span {:class (stl/css :register-text)}
(tr "auth.register") " "]
[:> lk/link* {:action go-register

View File

@ -6,8 +6,88 @@
@use "./common";
@use "ds/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/typography.scss" as *;
.forgot-password-row {
display: flex;
justify-content: flex-end;
}
.forgot-password-link {
@include use-typography("title-small");
text-align: left;
background-color: transparent;
border: none;
display: inline;
color: var(--color-accent-primary);
&:hover {
text-decoration: underline;
}
}
.form-submit-buttons {
display: grid;
gap: var(--sp-s);
}
.sso-icon {
max-inline-size: px2rem(26);
max-block-size: px2rem(26);
}
.sso-row {
display: flex;
gap: var(--sp-s);
}
.sso-btn {
--button-bg-color: var(--color-background-tertiary);
--button-fg-color: var(--color-foreground-primary);
@include use-typography("title-small");
display: flex;
align-items: center;
gap: px2rem(6);
inline-size: 100%;
border-radius: $br-8;
background-color: var(--button-bg-color);
color: var(--button-fg-color);
&:hover {
--button-bg-color: var(--color-background-quaternary);
--button-fg-color: var(--color-foreground-primary);
}
}
.register-row {
display: flex;
justify-content: center;
gap: var(--sp-s);
padding: 0;
}
.register-text {
@include use-typography("title-small");
text-align: right;
color: var(--color-foreground-secondary);
}
.register-link {
@include use-typography("title-small");
text-align: left;
background-color: transparent;
border: none;
display: inline;
color: var(--color-accent-primary);
&:hover {
text-decoration: underline;
}
}

View File

@ -60,39 +60,38 @@
:initial params)]
[:& fm/form {:on-submit on-submit
:class (stl/css :recovery-form)
:class (stl/css :form)
:form form}
[:div {:class (stl/css :fields-row)}
[:div {:class (stl/css :form-row)}
[:& fm/input {:type "password"
:name :password-1
:show-success? true
:label (tr "auth.new-password")
:class (stl/css :form-field)}]]
[:div {:class (stl/css :fields-row)}
[:div {:class (stl/css :form-row)}
[:& fm/input {:type "password"
:name :password-2
:show-success? true
:label (tr "auth.confirm-password")
:class (stl/css :form-field)}]]
[:> fm/submit-button*
{:label (tr "auth.recovery-submit")
:class (stl/css :submit-btn)}]]))
[:> fm/submit-button* {:label (tr "auth.recovery-submit")
:class (stl/css :form-submit-btn)}]]))
;; --- Recovery Request Page
(mf/defc recovery-page*
[{:keys [params]}]
[:div {:class (stl/css :auth-form-wrapper)}
[:h1 {:class (stl/css :auth-title)} "Forgot your password?"]
[:div {:class (stl/css :auth-subtitle)} "Please enter your new password"]
[:div {:class (stl/css :wrapper)}
[:h1 {:class (stl/css :title)} (tr "auth.recovery-request-title")]
[:div {:class (stl/css :subtitle)} (tr "auth.recovery-request-subtitle")]
[:hr {:class (stl/css :separator)}]
[:> recovery-form* {:params params}]
[:div {:class (stl/css :links)}
[:div {:class (stl/css :go-back)}
[:div {:class (stl/css :go-back-row)}
[:a {:on-click #(st/emit! (rt/nav :auth-login))
:class (stl/css :go-back-link)}
(tr "profile.recovery.go-to-login")]]]])

View File

@ -5,7 +5,3 @@
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "./common";
.submit-btn {
margin-block-start: var(--sp-l);
}

View File

@ -70,18 +70,17 @@
(st/emit! (du/request-profile-recovery params)))))]
[:& fm/form {:on-submit on-submit
:class (stl/css :recovery-request-form)
:class (stl/css :form)
:form form}
[:div {:class (stl/css :fields-row)}
[:div {:class (stl/css :form-row)}
[:& fm/input {:name :email
:label (tr "auth.work-email")
:type "text"
:class (stl/css :form-field)}]]
[:> fm/submit-button*
{:label (tr "auth.recovery-request-submit")
:data-testid "recovery-resquest-submit"
:class (stl/css :recover-btn)}]]))
[:> fm/submit-button* {:label (tr "auth.recovery-request-submit")
:data-testid "recovery-resquest-submit"
:class (stl/css :form-submit-btn)}]]))
;; --- Recovery Request Page
@ -90,14 +89,14 @@
[{:keys [params on-success-callback go-back-callback]}]
(let [default-go-back #(st/emit! (rt/nav :auth-login))
go-back (or go-back-callback default-go-back)]
[:div {:class (stl/css :auth-form-wrapper)}
[:h1 {:class (stl/css :auth-title)} (tr "auth.recovery-request-title")]
[:div {:class (stl/css :auth-subtitle)} (tr "auth.recovery-request-subtitle")]
[:div {:class (stl/css :wrapper)}
[:h1 {:class (stl/css :title)} (tr "auth.recovery-request-title")]
[:div {:class (stl/css :subtitle)} (tr "auth.recovery-request-subtitle")]
[:hr {:class (stl/css :separator)}]
[:> recovery-form* {:params params :on-success-callback on-success-callback}]
[:hr {:class (stl/css :separator)}]
[:div {:class (stl/css :go-back)}
[:div {:class (stl/css :go-back-row)}
[:> lk/link* {:action go-back
:class (stl/css :go-back-link)
:data-testid "go-back-link"}
@ -106,11 +105,10 @@
(mf/defc recovery-sent-page*
[{:keys [email]}]
[:div {:class (stl/css :auth-form-wrapper :register-success)}
[:div {:class (stl/css :auth-title-wrapper)}
[:h2 {:class (stl/css :auth-title)}
[:div {:class (stl/css :wrapper :register-success)}
[:div {:class (stl/css :title-wrapper)}
[:h2 {:class (stl/css :title)}
(tr "auth.check-email")]
[:div {:class (stl/css :notification-text)} (tr "not-found.login.sent-recovery")]]
[:div {:class (stl/css :notification-text-email)} email]
[:div {:class (stl/css :notification-email)} email]
[:div {:class (stl/css :notification-text)} (tr "not-found.login.sent-recovery-check")]])

View File

@ -4,19 +4,4 @@
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "ds/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/typography.scss" as *;
@use "./common";
.fields-row {
margin-block-end: var(--sp-s);
}
.notification-text-email {
@include use-typography("title-medium");
line-height: 1.2;
color: var(--color-accent-primary);
margin-inline: $sz-36;
}

View File

@ -26,39 +26,38 @@
;; --- PAGE: Register
(mf/defc newsletter-options*
(mf/defc newsletter-checkbox*
{::mf/private true}
[]
(let [updates-label
(let [newsletter-label
(mf/html
[:> i18n/tr-html*
{:tag-name "div"
:content (tr "onboarding-v2.newsletter.updates")}])]
[:div {:class (stl/css :fields-row :input-visible :newsletter-option-wrapper)}
[:> i18n/tr-html* {:tag-name "div"
:content (tr "onboarding-v2.newsletter.updates")}])]
[:div {:class (stl/css :form-row :input-visible :newsletter-option-wrapper)}
[:& fm/input {:name :accept-newsletter-updates
:class (stl/css :checkbox-newsletter-updates)
:type "checkbox"
:default-checked false
:label updates-label}]]))
:label newsletter-label}]]))
(mf/defc terms-and-privacy*
(mf/defc terms-and-privacy-checkbox*
{::mf/private true}
[]
(let [terms-label
(let [terms-and-privacy-label
(mf/html
[:> i18n/tr-html*
{:tag-name "div"
:content (tr "auth.terms-and-privacy-agreement"
cf/terms-of-service-uri
cf/privacy-policy-uri)}])]
[:> i18n/tr-html* {:tag-name "div"
:content (tr "auth.terms-and-privacy-agreement"
cf/terms-of-service-uri
cf/privacy-policy-uri)}])]
[:div {:class (stl/css :fields-row :input-visible :accept-terms-and-privacy-wrapper)}
[:div {:class (stl/css :form-row :input-visible :accept-terms-and-privacy-wrapper)}
[:& fm/input {:name :accept-terms-and-privacy
:show-error false
:class (stl/css :checkbox-terms-and-privacy)
:type "checkbox"
:default-checked false
:label terms-label}]]))
:label terms-and-privacy-label}]]))
(def ^:private schema:register-form
[:map {:title "RegisterForm"}
@ -76,8 +75,7 @@
form (fm/use-form :schema schema:register-form
:initial initial)
submitted?
(mf/use-state false)
submitted? (mf/use-state false)
on-error
(mf/use-fn
@ -167,22 +165,24 @@
(->> (rp/cmd! :prepare-register-profile cdata)
(rx/subs! on-register-profile on-error #(reset! submitted? false))))))]
[:& fm/form {:on-submit on-submit :form form}
[:div {:class (stl/css :fields-row)}
[:& fm/form {:on-submit on-submit
:form form
:class (stl/css :form)}
[:div {:class (stl/css :form-row)}
[:& fm/input {:name :fullname
:label (tr "auth.fullname")
:type "text"
:show-success? true
:class (stl/css :form-field)}]]
[:div {:class (stl/css :fields-row)}
[:div {:class (stl/css :form-row)}
[:& fm/input {:type "text"
:name :email
:label (tr "auth.work-email")
:data-testid "email-input"
:show-success? true
:class (stl/css :form-field)}]]
[:div {:class (stl/css :fields-row)}
[:div {:class (stl/css :form-row)}
[:& fm/input {:name :password
:hint (tr "auth.password-length-hint")
:label (tr "auth.password")
@ -191,21 +191,20 @@
:class (stl/css :form-field)}]]
(when (contains? cf/flags :terms-and-privacy-checkbox)
[:> terms-and-privacy*])
[:> terms-and-privacy-checkbox*])
[:> newsletter-options*]
[:> newsletter-checkbox*]
[:> fm/submit-button*
{:label (tr "auth.register-submit")
:disabled @submitted?
:data-testid "register-form-submit"
:class (stl/css :register-btn)}]]))
[:> fm/submit-button* {:label (tr "auth.register-submit")
:disabled @submitted?
:data-testid "register-form-submit"
:class (stl/css :form-submit-btn)}]]))
(mf/defc register-methods*
[{:keys [params hide-separator on-success-callback]}]
[:*
(when login/show-sso-login-buttons?
[:> login/login-sso-buttons* {:params params}])
[:> login/sso-buttons* {:params params}])
(when (or login/show-sso-login-buttons? (false? hide-separator))
[:hr {:class (stl/css :separator)}])
(when (contains? cf/flags :login-with-password)
@ -213,8 +212,8 @@
(mf/defc register-page*
[{:keys [params]}]
[:div {:class (stl/css :auth-form-wrapper :register-form)}
[:h1 {:class (stl/css :auth-title)
[:div {:class (stl/css :wrapper :register-form)}
[:h1 {:class (stl/css :title)
:data-testid "registration-title"} (tr "auth.register-title")]
(when (contains? cf/flags :demo-warning)
@ -223,7 +222,7 @@
[:> register-methods* {:params params}]
[:div {:class (stl/css :links)}
[:div {:class (stl/css :account)}
[:div {:class (stl/css :account-row)}
[:span {:class (stl/css :account-text)} (tr "auth.already-have-account") " "]
[:> lk/link* {:action #(st/emit! (rt/nav :auth-login params))
:class (stl/css :account-link)
@ -233,9 +232,9 @@
(when (contains? cf/flags :demo-users)
[:*
[:hr {:class (stl/css :separator)}]
[:div {:class (stl/css :demo-account)}
[:div {:class (stl/css :account-row)}
[:> lk/link* {:action login/create-demo-profile
:class (stl/css :demo-account-link)}
:class (stl/css :account-link)}
(tr "auth.create-demo-account")]]])]])
@ -244,31 +243,31 @@
(mf/defc register-success-page*
[{:keys [params]}]
(let [email (or (:email params) (::email storage/user))]
[:div {:class (stl/css :auth-form-wrapper :register-success)}
[:div {:class (stl/css :auth-title-wrapper)}
[:h2 {:class (stl/css :auth-title)}
[:div {:class (stl/css :wrapper :register-success)}
[:div {:class (stl/css :title-wrapper)}
[:h2 {:class (stl/css :register-success-title)}
(tr "auth.check-email")]
[:div {:class (stl/css :notification-text)} (tr "auth.verification-sent-email")]]
[:div {:class (stl/css :notification-text-email)} email]]))
[:div {:class (stl/css :notification-email)} email]]))
(mf/defc terms-register*
(mf/defc terms-service-privacy-policy*
[]
(let [show-all? (and cf/terms-of-service-uri cf/privacy-policy-uri)
show-terms? (some? cf/terms-of-service-uri)
show-privacy? (some? cf/privacy-policy-uri)]
(when show-all?
[:div {:class (stl/css :terms-register)}
[:div {:class (stl/css :terms)}
(when show-terms?
[:a {:href cf/terms-of-service-uri :target "_blank" :class (stl/css :auth-link)}
[:a {:href cf/terms-of-service-uri :target "_blank" :class (stl/css :terms-link)}
(tr "auth.terms-of-service")])
(when show-all?
[:span {:class (stl/css :and-text)}
[:span {:class (stl/css :terms-and)}
(dm/str " " (tr "labels.and") " ")])
(when show-privacy?
[:a {:href cf/privacy-policy-uri :target "_blank" :class (stl/css :auth-link)}
[:a {:href cf/privacy-policy-uri :target "_blank" :class (stl/css :terms-link)}
(tr "auth.privacy-policy")])])))
;; --- PAGE: register validation
@ -334,9 +333,9 @@
[:& fm/form {:on-submit on-submit
:form form
:class (stl/css :register-validate-form)}
:class (stl/css :auth-form)}
[:div {:class (stl/css :fields-row)}
[:div {:class (stl/css :form-row)}
[:& fm/input {:name :fullname
:label (tr "auth.fullname")
:type "text"
@ -344,30 +343,28 @@
:class (stl/css :form-field)}]]
(when (contains? cf/flags :terms-and-privacy-checkbox)
[:> terms-and-privacy*])
[:> terms-and-privacy-checkbox*])
[:> newsletter-options*]
[:> fm/submit-button*
{:label (tr "auth.register-submit")
:disabled @submitted?
:class (stl/css :register-btn)}]]))
[:> newsletter-checkbox*]
[:> fm/submit-button* {:label (tr "auth.register-submit")
:disabled @submitted?
:class (stl/css :form-submit-btn)}]]))
(mf/defc register-validate-page*
[{:keys [params]}]
[:div {:class (stl/css :auth-form-wrapper :register-form)}
[:div {:class (stl/css :wrapper :register-form)}
[:div {:class (stl/css :auth-title-wrapper)}
[:h2 {:class (stl/css :auth-title)
[:div {:class (stl/css :title-wrapper)}
[:h2 {:class (stl/css :title)
:data-testid "register-title"} (tr "auth.register-account-title")]
[:div {:class (stl/css :auth-subtitle)} (tr "auth.register-account-tagline")]]
[:div {:class (stl/css :subtitle)} (tr "auth.register-account-tagline")]]
[:> register-validate-form* {:params params}]
[:div {:class (stl/css :links)}
[:div {:class (stl/css :go-back)}
[:div {:class (stl/css :go-back-row)}
[:> lk/link* {:action #(st/emit! (rt/nav :auth-register {}))
:class (stl/css :go-back-link)}
(tr "labels.go-back")]]]])

View File

@ -4,57 +4,64 @@
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "./common";
@use "ds/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/typography.scss" as *;
@use "./common";
.checkbox-terms-and-privacy,
.checkbox-newsletter-updates {
align-items: flex-start;
}
.checkbox-terms-and-privacy {
align-items: flex-start;
}
.register-form {
gap: var(--sp-xxl);
}
.account-row {
display: flex;
justify-content: center;
gap: var(--sp-s);
padding: 0;
}
.account-text {
@include use-typography("title-small");
text-align: right;
color: var(--color-foreground-secondary);
}
.account-link {
@include use-typography("title-small");
text-align: left;
background-color: transparent;
border: none;
display: inline;
color: var(--color-accent-primary);
&:hover {
text-decoration: underline;
}
}
.register-success {
gap: var(--sp-xxl);
}
.register-success .auth-title {
.register-success-title {
@include use-typography("title-medium");
line-height: 1.2;
color: var(--color-foreground-primary);
}
.notification-text {
@include use-typography("body-medium");
color: var(--color-foreground-secondary);
}
.notification-text-email {
@include use-typography("title-medium");
line-height: 1.2;
color: var(--color-accent-primary);
margin-inline: $sz-36;
}
.logo-btn {
block-size: $sz-40;
}
.logo-container {
display: flex;
justify-content: flex-start;
inline-size: $sz-120;
margin-block-end: var(--sp-xxl);
}
.terms-register {
.terms {
@include use-typography("body-small");
display: flex;
@ -63,15 +70,15 @@
inline-size: 100%;
}
.and-text {
border-block-end: $b-1 solid transparent;
color: var(--color-foreground-secondary);
}
.auth-link {
.terms-link {
color: var(--color-accent-primary);
&:hover {
text-decoration: underline;
}
}
.terms-and {
border-block-end: $b-1 solid transparent;
color: var(--color-foreground-secondary);
}

View File

@ -114,6 +114,7 @@
display: grid;
width: 100%;
height: 100%;
min-block-size: 0;
outline: $b-1 solid var(--tab-panel-outline-color);
}

View File

@ -185,7 +185,7 @@
:on-click set-section} (tr "auth.login-here")]]
[:div {:class (stl/css :links)}
[:hr {:class (stl/css :separator)}]
[:> register/terms-register*]]]
[:> register/terms-service-privacy-policy*]]]
:register-validate
[:div {:class (stl/css :form-container)}

View File

@ -13,8 +13,10 @@
[app.main.store :as st]
[app.main.ui.auth.login :refer [login-dialog*]]
[app.main.ui.auth.recovery-request :refer [recovery-request-page*]]
[app.main.ui.auth.register :refer [register-methods* register-success-page* terms-register* register-validate-form*]]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.auth.register :refer [register-methods* register-success-page*
register-validate-form* terms-service-privacy-policy*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :as i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[rumext.v2 :as mf]))
@ -25,14 +27,15 @@
{::mf/register modal/components
::mf/register-as :login-register}
[_]
(let [user-email (mf/use-state "")
(let [user-email (mf/use-state "")
register-token (mf/use-state "")
current-section* (mf/use-state :login)
current-section (deref current-section*)
current-section (deref current-section*)
set-current-section
(mf/use-fn #(reset! current-section* %))
(mf/use-fn
#(reset! current-section* %))
set-section
(mf/use-fn
@ -42,7 +45,9 @@
(keyword))]
(set-current-section section))))
go-back-to-login (mf/use-fn #(set-current-section :login))
go-back-to-login
(mf/use-fn
#(set-current-section :login))
main-section (or
(= current-section :login)
@ -52,13 +57,16 @@
(fn [event]
(dom/prevent-default event)
(st/emit! (modal/hide)))
success-email-sent
(fn [email]
(reset! user-email email)
(set-current-section :email-sent))
success-login
(fn []
(.reload js/window.location true))
success-register
(fn [data]
(reset! register-token (:token data))
@ -67,49 +75,48 @@
[:div {:class (stl/css :modal-overlay)}
[:div {:class (stl/css :modal-container)}
[:div {:class (stl/css :modal-header)}
[:h2 {:class (stl/css :modal-title)} (tr "labels.continue-with-penpot")]
[:button {:class (stl/css :modal-close-btn)
:title (tr "labels.close")
:on-click close} deprecated-icon/close]]
[:h2 {:class (stl/css :modal-header-title)} (tr "labels.continue-with-penpot")]
[:> icon-button* {:variant "ghost"
:class (stl/css :modal-close)
:aria-label (tr "labels.close")
:on-click close
:icon i/close}]]
[:div {:class (stl/css :modal-content)}
(case current-section
:login
[:div {:class (stl/css :form-container)}
[:> login-dialog*
{:on-success-callback success-login
:origin :viewer}]
[:div {:class (stl/css :links)}
[:div {:class (stl/css :recovery-request)}
[:div {:class (stl/css :login-form)}
[:> login-dialog* {:on-success-callback success-login
:origin :viewer}]
[:div {:class (stl/css :login-links)}
[:div
[:a {:on-click set-section
:class (stl/css :recovery-link)
:data-value "recovery-request"}
(tr "auth.forgot-password")]]
(when (contains? cf/flags :registration)
[:div {:class (stl/css :register)}
[:span {:class (stl/css :register-text)}
[:div
[:span
(tr "auth.register") " "]
[:a {:on-click set-section
:class (stl/css :register-link)
:data-value "register"}
(tr "auth.register-submit")]])]]
:register
[:div {:class (stl/css :form-container)}
[:div {:class (stl/css :login-form)}
[:> register-methods* {:on-success-callback success-register}]
[:div {:class (stl/css :links)}
[:div {:class (stl/css :account)}
[:div {:class (stl/css :login-links)}
[:div
[:span (tr "auth.already-have-account") " "]
[:a {:on-click set-section
:data-value "login"}
(tr "auth.login-here")]]]]
:register-validate
[:div {:class (stl/css :form-container)}
[:div {:class (stl/css :login-form)}
[:> register-validate-form* {:params {:token @register-token}
:on-success-callback success-email-sent}]
[:div {:class (stl/css :links)}
[:div {:class (stl/css :register)}
[:div {:class (stl/css :login-links)}
[:div
[:a {:on-click set-section
:data-value "register"}
(tr "labels.go-back")]]]]
@ -117,10 +124,11 @@
:recovery-request
[:> recovery-request-page* {:go-back-callback go-back-to-login
:on-success-callback success-email-sent}]
:email-sent
[:div {:class (stl/css :form-container)}
[:div {:class (stl/css :login-form)}
[:> register-success-page* {:params {:email @user-email}}]])
(when main-section
[:div {:class (stl/css :links)}
[:> terms-register*]])]]]))
[:div {:class (stl/css :login-links)}
[:> terms-service-privacy-policy*]])]]]))

View File

@ -4,75 +4,71 @@
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "refactor/common-refactor.scss" as deprecated;
@use "ds/_utils.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "ds/typography.scss" as *;
@use "ds/z-index.scss" as *;
.modal-overlay {
@extend %modal-overlay-base;
display: flex;
justify-content: center;
align-items: center;
position: fixed;
inset: 0;
block-size: 100%;
inline-size: 100%;
z-index: var(--z-index-set);
background-color: var(--color-overlay-default);
}
.modal-container {
@extend %modal-container-base;
width: deprecated.$s-368;
position: relative;
padding: var(--sp-xxxl);
border-radius: $br-8;
background-color: var(--color-background-primary);
border: $b-2 solid var(--color-background-quaternary);
min-inline-size: $sz-364;
min-block-size: $sz-192;
max-inline-size: $sz-512;
max-block-size: $sz-712;
inline-size: $sz-364;
}
.modal-header {
margin-bottom: deprecated.$s-24;
margin-block-end: var(--sp-xxl);
}
.modal-title {
@include deprecated.uppercase-title-typography;
.modal-header-title {
@include use-typography("headline-small");
color: var(--modal-title-foreground-color);
color: var(--color-foreground-primary);
}
.modal-close-btn {
@extend %modal-close-btn-base;
.modal-close {
position: absolute;
inset-block-start: var(--sp-s);
inset-inline-end: var(--sp-s);
}
.modal-content {
@include deprecated.flex-column;
@include deprecated.body-small-typography;
@include use-typography("body-small");
gap: deprecated.$s-24;
max-height: deprecated.$s-400;
display: flex;
flex-direction: column;
gap: var(--sp-xxl);
max-block-size: px2rem(576);
overflow: hidden auto;
form {
display: flex;
flex-direction: column;
margin-bottom: 1.5rem;
gap: 0.75rem;
}
}
.form-container {
.login-form {
display: flex;
justify-content: center;
flex-direction: column;
gap: var(--sp-m);
}
.links {
.login-links {
position: relative;
}
.link-entry {
display: flex;
flex-direction: column;
gap: deprecated.$s-12;
span {
text-align: center;
font-size: deprecated.$fs-14;
color: var(--modal-text-foreground-color);
margin-top: deprecated.$s-12;
}
a {
@extend %button-secondary;
height: deprecated.$s-40;
text-transform: uppercase;
font-size: deprecated.$fs-11;
}
color: var(--color-foreground-primary);
}

View File

@ -97,9 +97,7 @@
sitemap-height
(if sitemap-collapsed? 32 height)]
[:article {:class (stl/css :layers-tab)
:style {:--height (dm/str height "px")}}
[:article {:class (stl/css :layers-tab)}
[:> sitemap* {:layout layout
:height sitemap-height
:collapsed sitemap-collapsed?

View File

@ -79,13 +79,15 @@
.layers-tab {
padding-block-start: var(--sp-xs);
overflow-x: hidden;
display: flex;
flex-direction: column;
overflow: hidden;
min-block-size: 0;
}
.layers-tab-resize-area {
background-color: var(--color-background-primary);
position: absolute;
inset-inline-start: 0;
flex: 0 0 auto;
inline-size: 100%;
padding: px2rem(3) 0 px2rem(1);
block-size: $sz-6;
@ -120,11 +122,16 @@
.left-sidebar-content {
grid-area: content;
inset-inline-end: calc(-1 * var(--sp-s));
min-block-size: 0;
overflow: hidden;
}
.left-sidebar-tabs {
--tabs-nav-padding-inline-start: var(--sp-m);
--tabs-nav-padding-inline-end: var(--sp-m);
block-size: 100%;
min-block-size: 0;
}
.left-sidebar-resize-area {

View File

@ -23,7 +23,7 @@
align-items: center;
justify-content: space-between;
gap: var(--sp-xs);
margin: var(--sp-m) var(--sp-m) 0 var(--sp-m);
margin: px2rem(6) var(--sp-m) 0 var(--sp-m);
}
.tool-window-bar-title {
@ -31,11 +31,10 @@
}
.tool-window-content {
--calculated-height: calc(#{px2rem(136)} + var(--height, #{$sz-200}));
display: flex;
flex-direction: column;
block-size: calc(100vh - var(--calculated-height));
flex: 1 1 auto;
min-block-size: 0;
inline-size: calc(var(--left-sidebar-width) + var(--depth) * var(--layer-indentation-size));
overflow: auto;
scrollbar-gutter: stable;
@ -117,6 +116,10 @@
.layers {
position: relative;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-block-size: 0;
}
.replace-wrapper {

View File

@ -15,7 +15,8 @@
position: relative;
display: flex;
flex-direction: column;
flex: 1;
flex: 0 0 auto;
flex-shrink: 0;
inline-size: 100%;
block-size: var(--height, $sz-200);
}

View File

@ -150,16 +150,26 @@
on-main-key-down
(mf/use-fn
(mf/deps open)
(fn [event]
(cond
(kbd/space? event)
(and open (kbd/esc? event))
(reset! open* false)
(or (kbd/enter? event) (kbd/space? event))
(do
(dom/prevent-default event)
(if open
(reset! open* false)
(do
(cancel-timer! close-timer*)
(reset! open* true))))
(kbd/down-arrow? event)
(do
(dom/prevent-default event)
(cancel-timer! close-timer*)
(reset! open* true))
(and open (kbd/esc? event))
(reset! open* false))))
(reset! open* true)))))
on-flyout-key-down
(mf/use-fn
@ -219,7 +229,10 @@
:aria-expanded open
:has-tooltip false
:icon default-icon
:on-click on-select-tool
:on-click (fn [event]
(cancel-timer! open-timer*)
(cancel-timer! close-timer*)
(on-select-tool event))
:on-key-down on-main-key-down
:data-tool (name default-tool)}]

View File

@ -282,8 +282,7 @@
[code value]
(if (some? value)
(throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid: " value ". Code: " code)))
(throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code))))
nil)
(throw (js/Error. (dm/str "[PENPOT PLUGIN] Value not valid. Code: " code)))))
(defn not-valid
[plugin-id code value]

View File

@ -19,7 +19,7 @@
"@types/node": "^26.1.2",
"@vitest/browser": "^4.1.10",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.10",
"@vitest/ui": "^4.1.11",
"canvas": "^3.2.3",
"esbuild": "^0.28.0",
"jsdom": "^30.0.1",
@ -28,5 +28,5 @@
"vite": "^8.2.0",
"vitest": "^4.1.10"
},
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee"
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
}

View File

@ -3,7 +3,7 @@
"version": "1.2.0-RC1",
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"type": "module",
"repository": {
"type": "git",
@ -36,7 +36,7 @@
"devDependencies": {
"@types/node": "^26.1.2",
"@zip.js/zip.js": "2.8.34",
"concurrently": "^10.0.4",
"concurrently": "^10.0.5",
"date-fns": "^4.4.0",
"nodemon": "^3.1.14",
"source-map-support": "^0.5.21"

111
library/pnpm-lock.yaml generated
View File

@ -1,3 +1,104 @@
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.0.0
version: 12.0.0
packages:
'@pnpm/exe.darwin-arm64@12.0.0':
resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==}
cpu: [arm64]
os: [darwin]
'@pnpm/exe.darwin-x64@12.0.0':
resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==}
cpu: [x64]
os: [darwin]
'@pnpm/exe.linux-arm64-musl@12.0.0':
resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-arm64@12.0.0':
resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/exe.linux-x64-musl@12.0.0':
resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/exe.linux-x64@12.0.0':
resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/exe.win32-arm64@12.0.0':
resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==}
cpu: [arm64]
os: [win32]
'@pnpm/exe.win32-x64@12.0.0':
resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==}
cpu: [x64]
os: [win32]
pnpm@12.0.0:
resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/exe.darwin-arm64@12.0.0':
optional: true
'@pnpm/exe.darwin-x64@12.0.0':
optional: true
'@pnpm/exe.linux-arm64-musl@12.0.0':
optional: true
'@pnpm/exe.linux-arm64@12.0.0':
optional: true
'@pnpm/exe.linux-x64-musl@12.0.0':
optional: true
'@pnpm/exe.linux-x64@12.0.0':
optional: true
'@pnpm/exe.win32-arm64@12.0.0':
optional: true
'@pnpm/exe.win32-x64@12.0.0':
optional: true
pnpm@12.0.0:
optionalDependencies:
'@pnpm/exe.darwin-arm64': 12.0.0
'@pnpm/exe.darwin-x64': 12.0.0
'@pnpm/exe.linux-arm64': 12.0.0
'@pnpm/exe.linux-arm64-musl': 12.0.0
'@pnpm/exe.linux-x64': 12.0.0
'@pnpm/exe.linux-x64-musl': 12.0.0
'@pnpm/exe.win32-arm64': 12.0.0
'@pnpm/exe.win32-x64': 12.0.0
---
lockfileVersion: '9.0'
settings:
@ -18,8 +119,8 @@ importers:
specifier: 2.8.34
version: 2.8.34(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95)
concurrently:
specifier: ^10.0.4
version: 10.0.4
specifier: ^10.0.5
version: 10.0.5
date-fns:
specifier: ^4.4.0
version: 4.4.0
@ -82,8 +183,8 @@ packages:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
concurrently@10.0.4:
resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==}
concurrently@10.0.5:
resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==}
engines: {node: '>=22'}
hasBin: true
@ -304,7 +405,7 @@ snapshots:
strip-ansi: 7.2.0
wrap-ansi: 9.0.2
concurrently@10.0.4:
concurrently@10.0.5:
dependencies:
chalk: 5.6.2
rxjs: 7.8.2

View File

@ -223,8 +223,10 @@ function ensure-devenv-network {
# those stale values would leak into substitution. And because Docker Compose
# gives shell-env precedence over --env-file, the re-injected per-instance
# overrides cleanly override the defaults.env baseline. Re-injected: HOME/PATH
# (tooling), CURRENT_USER_ID/PENPOT_SOURCE_PATH (always per-call), and the
# instance-env-overrides block.
# (tooling), CURRENT_USER_ID/PENPOT_SOURCE_PATH (always per-call), the
# optional PENPOT_OPENCODE_CONFIG_DIR (set only by run-devenv's
# --opencode-config-dir within this process), and the instance-env-overrides
# block.
function infra-compose {
env -i HOME="$HOME" PATH="$PATH" PWD="$PWD" \
docker compose -p penpotdev-infra \
@ -245,14 +247,26 @@ function instance-compose {
# Per-instance overrides apply to all workspaces uniformly.
mapfile -t overrides < <(instance-env-overrides "$instance")
# Optional personal-opencode-config overlay: the extra -f and variable
# are only present when run-devenv --opencode-config-dir resolved a host
# directory into PENPOT_OPENCODE_CONFIG_DIR; when unset neither the file
# nor the variable is referenced, so default behaviour is unchanged.
local -a compose_files=(-f docker/devenv/docker-compose.main.yml)
local -a opencode_env=()
if [[ -n "${PENPOT_OPENCODE_CONFIG_DIR:-}" ]]; then
compose_files+=(-f docker/devenv/docker-compose.opencode.yml)
opencode_env=("PENPOT_OPENCODE_CONFIG_DIR=${PENPOT_OPENCODE_CONFIG_DIR}")
fi
env -i HOME="$HOME" PATH="$PATH" PWD="$PWD" \
CURRENT_USER_ID="${CURRENT_USER_ID:-$(id -u)}" \
PENPOT_SOURCE_PATH="$source_path" \
DEVENV_TAG="$DEVENV_TAG" \
"${opencode_env[@]}" \
"${overrides[@]}" \
docker compose -p "penpotdev-${instance}" \
--env-file "$DEVENV_DEFAULTS_FILE" \
-f docker/devenv/docker-compose.main.yml \
"${compose_files[@]}" \
"$@"
}
@ -658,6 +672,26 @@ function parse-ws-integer {
echo "ws$raw"
}
# Strict parser for --opencode-config-dir. Resolves the value to an absolute
# host directory (docker compose resolves relative bind-mount sources against
# the compose file's directory, not $PWD, so relative values would be
# misinterpreted) and verifies it exists. Echoes the absolute path; anything
# else fails fast.
function parse-opencode-config-dir {
local raw="$1"
if [[ -z "$raw" ]]; then
echo "Invalid --opencode-config-dir: value is empty." >&2
return 1
fi
raw="${raw/#\~/$HOME}"
local abs
if ! abs=$(realpath -e "$raw" 2>/dev/null) || [[ ! -d "$abs" ]]; then
echo "Invalid --opencode-config-dir: '$raw' is not an existing directory." >&2
return 1
fi
echo "$abs"
}
# Bring a single instance up: compose up + detached tmux start. When agentic
# is true (the default) the tmux session gets MCP + Serena enabled; when false
@ -764,6 +798,7 @@ function run-devenv {
local serena_context="desktop-app"
local git_user_name=""
local git_user_email=""
local opencode_config_dir=""
local -a extra_env_args=()
while [[ $# -gt 0 ]]; do
@ -778,6 +813,8 @@ function run-devenv {
do_attach=true; shift;;
--serena-context)
serena_context="$2"; shift 2;;
--opencode-config-dir)
opencode_config_dir="$(parse-opencode-config-dir "$2")" || return 1; shift 2;;
--git-user-name)
git_user_name="$2"; shift 2;;
--git-user-email)
@ -787,13 +824,16 @@ function run-devenv {
-e*)
extra_env_args+=(-e "${1#-e}"); shift;;
-h|--help)
echo "Usage: run-devenv [--ws N] [--sync] [--attach] [--agentic] [--serena-context CTX] [--git-user-name NAME] [--git-user-email EMAIL] [-e KEY=VAL]"
echo "Usage: run-devenv [--ws N] [--sync] [--attach] [--agentic] [--serena-context CTX] [--opencode-config-dir DIR] [--git-user-name NAME] [--git-user-email EMAIL] [-e KEY=VAL]"
echo " Bring a single workspace up."
echo " --ws N target workspace (default: 0)."
echo " --sync re-seed the wsN clone from the live repo (forbidden on ws0)."
echo " --attach attach to the tmux session after startup."
echo " --agentic enable MCP + Serena (AI-agent mode)."
echo " --serena-context CTX context passed to Serena (default: desktop-app)."
echo " --opencode-config-dir DIR bind-mount DIR at ~/.config/opencode inside the"
echo " container (personal agents/prompts/skills kept in a"
echo " separate repo). Applied at container creation."
echo " --git-user-name NAME git author name inside the container (default: host git config)."
echo " --git-user-email EMAIL git author email inside the container."
echo " -e KEY=VAL forward env var to docker exec on attach."
@ -863,6 +903,14 @@ function run-devenv {
write-instance-mcp-configs "$target"
fi
# Scope the personal opencode config to this process only: instance-compose
# includes the overlay compose file and the variable just when it is set,
# so instances brought up without the flag mount nothing.
if [[ -n "$opencode_config_dir" ]]; then
echo "[$target] mounting personal opencode config: $opencode_config_dir -> ~/.config/opencode"
export PENPOT_OPENCODE_CONFIG_DIR="$opencode_config_dir"
fi
echo "Starting $target..."
start-instance "$target" "$serena_context" "$git_user_name" "$git_user_email" "$agentic"
print-instance-info "$target"
@ -1338,6 +1386,11 @@ function usage {
echo " --attach attach to the tmux session after startup."
echo " --agentic enable MCP + Serena (AI-agent mode)."
echo " --serena-context CTX passed to Serena (default: desktop-app)."
echo " --opencode-config-dir DIR"
echo " bind-mount DIR over the container's"
echo " ~/.config/opencode (personal opencode"
echo " agents/prompts/skills kept outside this"
echo " repo; applied at container creation)."
echo " -e KEY=VAL forwarded to 'docker exec' on attach."
echo " --git-user-name NAME / --git-user-email EMAIL"
echo " identity wired into the container's git config"

View File

@ -265,6 +265,7 @@ The Penpot MCP server can be configured using environment variables.
| `PENPOT_MCP_SERVER_PORT` | Port for the HTTP/SSE server | `4401` |
| `PENPOT_MCP_WEBSOCKET_PORT` | Port for the WebSocket server (plugin connection) | `4402` |
| `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` |
| `PENPOT_MCP_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. | (unset) |
| `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` |
| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` |
| `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` |

View File

@ -23,9 +23,9 @@
"type": "git",
"url": "https://github.com/penpot/penpot.git"
},
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"devDependencies": {
"concurrently": "^10.0.4",
"concurrently": "^10.0.5",
"prettier": "^3.9.6"
}
}

View File

@ -4,7 +4,7 @@
"description": "Shared type definitions and interfaces for Penpot MCP",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"scripts": {
"build": "tsc --build --clean && tsc --build",
"watch": "tsc --watch",

View File

@ -1,5 +1,5 @@
import { defineConfig, mergeConfig } from "vite";
import baseConfig from "./vite.config";
import baseConfig from "./vite.config.ts";
export default mergeConfig(
baseConfig,

View File

@ -24,7 +24,7 @@
],
"author": "",
"license": "MIT",
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee",
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"class-transformer": "^0.5.1",

View File

@ -0,0 +1,149 @@
import assert from "node:assert/strict";
import test from "node:test";
import { PenpotMcpServer } from "./PenpotMcpServer";
// ── Pure function tests ────────────────────────────────────────
test("isDevEnvEnabled returns false when PENPOT_MCP_DEVENV is not set", () => {
assert.equal(PenpotMcpServer.isDevEnvEnabled({}), false);
});
test("isDevEnvEnabled returns false when PENPOT_MCP_DEVENV is 'false'", () => {
assert.equal(PenpotMcpServer.isDevEnvEnabled({ PENPOT_MCP_DEVENV: "false" }), false);
});
test("isDevEnvEnabled returns true when PENPOT_MCP_DEVENV is 'true'", () => {
assert.equal(PenpotMcpServer.isDevEnvEnabled({ PENPOT_MCP_DEVENV: "true" }), true);
});
// ── Pure function tests: isReplEnabled ──────────────────────────
test("isReplEnabled returns false when neither env var is set", () => {
assert.equal(PenpotMcpServer.isReplEnabled({}), false);
});
test("isReplEnabled returns true when PENPOT_MCP_DEVENV is 'true' (fallback)", () => {
assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_DEVENV: "true" }), true);
});
test("isReplEnabled returns true when PENPOT_MCP_REPL_ENABLE is 'true'", () => {
assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_REPL_ENABLE: "true" }), true);
});
test("isReplEnabled returns false when PENPOT_MCP_REPL_ENABLE is 'false' even if DEVENV is true", () => {
assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_REPL_ENABLE: "false", PENPOT_MCP_DEVENV: "true" }), false);
});
test("isReplEnabled returns true when PENPOT_MCP_REPL_ENABLE is 'true' regardless of DEVENV", () => {
assert.equal(PenpotMcpServer.isReplEnabled({ PENPOT_MCP_REPL_ENABLE: "true" }), true);
});
// ── Integration tests: constructor gating ──────────────────────
//
// Each test uses unique ports to avoid conflicts when tests run
// in the same process. The server is stopped in the finally block
// to release the WebSocket port.
let portCounter = 14_500;
function uniquePorts() {
const base = portCounter;
portCounter += 10;
return { server: base, ws: base + 1, repl: base + 2 };
}
test("constructor does not create ReplServer when PENPOT_MCP_DEVENV is unset", async () => {
const prev = process.env.PENPOT_MCP_DEVENV;
const prevPorts = setUniqueEnv();
delete process.env.PENPOT_MCP_DEVENV;
let server: PenpotMcpServer | undefined;
try {
server = new PenpotMcpServer(false);
assert.equal(server.hasReplServer(), false);
} finally {
await server?.stop();
restoreEnv(prev, prevPorts);
}
});
test("constructor creates ReplServer when PENPOT_MCP_DEVENV is 'true'", async () => {
const prev = process.env.PENPOT_MCP_DEVENV;
const prevPorts = setUniqueEnv();
process.env.PENPOT_MCP_DEVENV = "true";
let server: PenpotMcpServer | undefined;
try {
server = new PenpotMcpServer(false);
assert.equal(server.hasReplServer(), true);
} finally {
await server?.stop();
restoreEnv(prev, prevPorts);
}
});
test("constructor creates ReplServer when PENPOT_MCP_REPL_ENABLE is 'true' without DEVENV", async () => {
const prevDevEnv = process.env.PENPOT_MCP_DEVENV;
const prevReplEnable = process.env.PENPOT_MCP_REPL_ENABLE;
const prevPorts = setUniqueEnv();
delete process.env.PENPOT_MCP_DEVENV;
process.env.PENPOT_MCP_REPL_ENABLE = "true";
let server: PenpotMcpServer | undefined;
try {
server = new PenpotMcpServer(false);
assert.equal(server.hasReplServer(), true);
} finally {
await server?.stop();
restoreEnv(prevDevEnv, prevPorts);
restoreOrDelete("PENPOT_MCP_REPL_ENABLE", prevReplEnable);
}
});
test("constructor does not create ReplServer when PENPOT_MCP_REPL_ENABLE is 'false' even with DEVENV", async () => {
const prevDevEnv = process.env.PENPOT_MCP_DEVENV;
const prevReplEnable = process.env.PENPOT_MCP_REPL_ENABLE;
const prevPorts = setUniqueEnv();
process.env.PENPOT_MCP_DEVENV = "true";
process.env.PENPOT_MCP_REPL_ENABLE = "false";
let server: PenpotMcpServer | undefined;
try {
server = new PenpotMcpServer(false);
assert.equal(server.hasReplServer(), false);
} finally {
await server?.stop();
restoreEnv(prevDevEnv, prevPorts);
restoreOrDelete("PENPOT_MCP_REPL_ENABLE", prevReplEnable);
}
});
// ── Helpers ────────────────────────────────────────────────────
function setUniqueEnv() {
const ports = uniquePorts();
const prevServer = process.env.PENPOT_MCP_SERVER_PORT;
const prevWs = process.env.PENPOT_MCP_WEBSOCKET_PORT;
const prevRepl = process.env.PENPOT_MCP_REPL_PORT;
process.env.PENPOT_MCP_SERVER_PORT = String(ports.server);
process.env.PENPOT_MCP_WEBSOCKET_PORT = String(ports.ws);
process.env.PENPOT_MCP_REPL_PORT = String(ports.repl);
return { prevServer, prevWs, prevRepl };
}
function restoreEnv(
devEnv: string | undefined,
ports: { prevServer: string | undefined; prevWs: string | undefined; prevRepl: string | undefined }
) {
if (devEnv !== undefined) {
process.env.PENPOT_MCP_DEVENV = devEnv;
} else {
delete process.env.PENPOT_MCP_DEVENV;
}
restoreOrDelete("PENPOT_MCP_SERVER_PORT", ports.prevServer);
restoreOrDelete("PENPOT_MCP_WEBSOCKET_PORT", ports.prevWs);
restoreOrDelete("PENPOT_MCP_REPL_PORT", ports.prevRepl);
}
function restoreOrDelete(key: string, value: string | undefined) {
if (value !== undefined) {
process.env[key] = value;
} else {
delete process.env[key];
}
}

View File

@ -56,6 +56,30 @@ export class PenpotMcpServer {
*/
private static readonly SESSION_TIMEOUT_MINUTES = 60;
/**
* Determines whether the server is running in a Penpot development
* environment, based on the given environment variables.
*
* Returns ``true`` only when ``PENPOT_MCP_DEVENV`` is ``"true"``.
*/
public static isDevEnvEnabled(env: Record<string, string | undefined>): boolean {
return env.PENPOT_MCP_DEVENV === "true";
}
/**
* Determines whether the REPL server should be enabled.
*
* If ``PENPOT_MCP_REPL_ENABLE`` is set, its value controls the result
* (``"true"`` enables, any other value disables). When the variable is
* not set, the result falls back to {@link isDevEnvEnabled}.
*/
public static isReplEnabled(env: Record<string, string | undefined>): boolean {
if (env.PENPOT_MCP_REPL_ENABLE !== undefined) {
return env.PENPOT_MCP_REPL_ENABLE === "true";
}
return PenpotMcpServer.isDevEnvEnabled(env);
}
/**
* Returns a short, non-reversible fingerprint of a user token, suitable for
* correlating log lines without exposing the full credential.
@ -83,7 +107,7 @@ export class PenpotMcpServer {
public readonly configLoader: ConfigurationLoader;
private app: any;
public readonly pluginBridge: PluginBridge;
private readonly replServer: ReplServer;
private readonly replServer: ReplServer | null;
private apiDocs: ApiDocs;
private readonly penpotHighLevelOverview: string;
private readonly connectionInstructions: string;
@ -149,7 +173,12 @@ export class PenpotMcpServer {
}
this.pluginBridge = new PluginBridge(this, this.webSocketPort, toolTimeoutSecs, this.redisBridge);
this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host);
if (PenpotMcpServer.isReplEnabled(process.env)) {
this.replServer = new ReplServer(this.pluginBridge, this.replPort, this.host);
} else {
this.replServer = null;
}
}
/**
@ -190,7 +219,18 @@ export class PenpotMcpServer {
* additional developer tools such as ClojureScript expression evaluation are exposed.
*/
public isDevEnv(): boolean {
return process.env.PENPOT_MCP_DEVENV === "true";
return PenpotMcpServer.isDevEnvEnabled(process.env);
}
/**
* Indicates whether the REPL server was created.
*
* The REPL server is created when {@link isReplEnabled} returns true,
* which means either ``PENPOT_MCP_REPL_ENABLE=true`` or, when that
* variable is unset, ``PENPOT_MCP_DEVENV=true``.
*/
public hasReplServer(): boolean {
return this.replServer !== null;
}
/**
@ -421,8 +461,14 @@ export class PenpotMcpServer {
this.logger.info(`Legacy SSE endpoint: http://${this.host}:${this.port}/sse`);
this.logger.info(`WebSocket server URL: ws://${this.host}:${this.webSocketPort}`);
// start the REPL server and session timeout checker
await this.replServer.start();
// start the REPL server (devenv only) and session timeout checker
if (this.replServer) {
await this.replServer.start();
} else {
this.logger.info(
"REPL server disabled (set PENPOT_MCP_REPL_ENABLE=true or PENPOT_MCP_DEVENV=true to enable)"
);
}
this.startSessionTimeoutChecker();
resolve();
@ -438,8 +484,11 @@ export class PenpotMcpServer {
public async stop(): Promise<void> {
this.logger.info("Stopping Penpot MCP Server...");
clearInterval(this.sessionTimeoutInterval);
await this.pluginBridge.close();
await this.redisBridge?.close();
await this.replServer.stop();
if (this.replServer) {
await this.replServer.stop();
}
this.logger.info("Penpot MCP Server stopped");
}
}

View File

@ -467,4 +467,16 @@ export class PluginBridge {
task.rejectWithError(error instanceof Error ? error : new Error(String(error)));
}
}
/**
* Closes the WebSocket server and all connected client sockets.
*/
public async close(): Promise<void> {
return new Promise((resolve) => {
this.wsServer.close(() => {
this.logger.info("WebSocket server closed");
resolve();
});
});
}
}

1046
mcp/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,3 @@
# auto-confirm node_modules purge when pnpm detects an incompatible modules
# directory (e.g. after a store location or pnpm major version change),
# preventing the interactive prompt from blocking bootstrap
confirmModulesPurge: false
allowBuilds:
esbuild: true
sharp: false
@ -19,9 +14,9 @@ minimumReleaseAgeExclude:
- hono@4.12.34
packages:
- "./packages/common"
- "./packages/server"
- "./packages/plugin"
- "packages/common"
- "packages/server"
- "packages/plugin"
overrides:
'@hono/node-server@<2.0.5': ^2.0.5

View File

@ -14,7 +14,7 @@
"fmt:check": "prettier --check src/ test/",
"clean": "rm -rf dist/"
},
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c",
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67",
"dependencies": {
"express": "^5.2.1",
"multer": "^2.2.0",
@ -22,19 +22,19 @@
"pino": "^10.3.1",
"pino-loki": "^3.0.0",
"pino-pretty": "^13.1.3",
"sharp": "^0.35.3",
"sharp": "^0.35.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/multer": "^2.0.0",
"@types/node": "^26.1.2",
"@types/node": "^26.3.0",
"@types/supertest": "^7.2.1",
"esbuild": "^0.28.1",
"esbuild": "^0.28.2",
"prettier": "^3.6.2",
"supertest": "^7.2.2",
"tsx": "^4.22.4",
"tsx": "^4.23.12",
"typescript": "^7.0.2",
"vitest": "^4.1.10"
"vitest": "^4.1.11"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -13,5 +13,5 @@
"lint": "eslint .",
"test": "vitest"
},
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee"
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
}

View File

@ -23,8 +23,8 @@
"playwright": "^1.62.1",
"prettier": "^3.9.6",
"typescript": "^5.9.3",
"vite": "^8.2.0",
"vite": "^8.2.2",
"vite-live-preview": "^0.4.0"
},
"packageManager": "pnpm@11.20.0+sha512.9a6f330a95b66446ea088faf1521405a8a01f07fde7124cc9958dfed52d4bb436737e65b08f85f37b46fcba375092558ac51262b816844b22f63406ed166bfee"
"packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67"
}

Some files were not shown because too many files have changed in this diff Show More