🎉 Add error reports API and CLI tool

Implement RPC methods for querying server error reports with pagination
and filtering. Add CLI tool (tools/error-reports.mjs) for convenient
access with table and JSON output formats. Extract profile-id from audit
events and logging context for better error categorization. Build
improved HREF using request path when available.

AI-assisted-by: qwen3.7-plus
This commit is contained in:
Andrey Antukh 2026-07-21 15:47:56 +00:00
parent 018d840bab
commit 2344ba22a6
22 changed files with 1244 additions and 92 deletions

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
# Penpot API configuration for error-reports CLI tool
PENPOT_API_URI=http://localhost:3450
PENPOT_ACCESS_TOKEN=your-access-token-here

View File

@ -0,0 +1,228 @@
# Error Reports CLI Tool
`scripts/error-reports.mjs` is a Node.js CLI tool for querying Penpot error reports via the RPC API. Provides access to error logs with filtering, pagination, and multiple output formats.
## When to use
- Querying error reports from the database for debugging or analysis
- Filtering errors by source, kind, tenant, or backend version
- Exporting error data in JSON or table format
- Investigating specific error reports by ID
## Prerequisites
- Node.js with `commander` and `dotenv` packages installed (in root `package.json`)
- Running Penpot backend with error-reports RPC endpoints
- Access token with `error-reports:read` permission
## Configuration
Create a `.env` file in the project root:
```bash
PENPOT_API_URI=http://localhost:3450
PENPOT_ACCESS_TOKEN=<your-token>
```
Grant the required permission to your access token:
```sql
UPDATE access_token
SET perms = ARRAY['error-reports:read']::text[],
updated_at = now()
WHERE id = '<token-uuid>';
```
## Usage
```bash
./scripts/error-reports.mjs <command> [options]
```
### Commands
#### `list` - List error reports with pagination and filters
```bash
./scripts/error-reports.mjs list [options]
```
**Options:**
| Flag | Description | Default |
|------|-------------|---------|
| `-l, --limit <n>` | Max items per page (max: 200) | `50` |
| `--since <date>` | ISO timestamp (fetch errors before this date) | — |
| `--since-id <uuid>` | Fetch errors before this ID (cursor pagination) | — |
| `-s, --source <name>` | Filter by source (see source names below) | — |
| `-p, --profile-id <uuid>` | Filter by profile ID | — |
| `-k, --kind <kind>` | Filter by kind (string) | — |
| `-t, --tenant <tenant>` | Filter by tenant (string) | — |
| `--version <version>` | Filter by version | — |
| `--hint <text>` | Filter by hint (ILIKE match) | — |
| `-a, --all` | Fetch all pages automatically | `false` |
| `-f, --format <type>` | Output format: `json` or `table` | `json` |
| `--env <path>` | Custom .env file path | `.env` |
| `-h, --help` | Show help message | — |
#### `get` - Get a single error report by ID
```bash
./scripts/error-reports.mjs get [options]
```
**Options:**
| Flag | Description | Required |
|------|-------------|----------|
| `--id <uuid>` | Error report ID | Yes (or --error-id) |
| `--error-id <id>` | Error report error-id | Yes (or --id) |
| `-f, --format <type>` | Output format: `json` or `table` | No (default: `json`) |
| `--env <path>` | Custom .env file path | No (default: `.env`) |
| `-h, --help` | Show help message | No |
## Source Names
The `--source` filter accepts these values:
- `logging`
- `audit-log`
- `rlimit`
## Examples
### List recent errors
```bash
./scripts/error-reports.mjs list --limit 10
```
### Filter by source
```bash
./scripts/error-reports.mjs list --source audit-log --limit 20
```
### Filter by kind
```bash
./scripts/error-reports.mjs list --kind exception-page
```
### Filter by tenant
```bash
./scripts/error-reports.mjs list --tenant production
```
### Filter by version
```bash
./scripts/error-reports.mjs list --version 2.1.0
```
### Search by hint (partial match)
```bash
./scripts/error-reports.mjs list --hint "NullPointerException"
```
### Fetch all errors with pagination
```bash
./scripts/error-reports.mjs list --all --format json
```
### Get specific error by ID
```bash
./scripts/error-reports.mjs get --id 550e8400-e29b-41d4-a716-446655440000
```
### Output as JSON
```bash
./scripts/error-reports.mjs list --limit 5 --format json
```
### Combine filters
```bash
./scripts/error-reports.mjs list --source audit-log --kind exception-page --tenant production --limit 50
```
## Output Formats
### Table (default)
Human-readable table format for terminal display:
```
Found 15 error reports
ID | Created At | Source | Profile ID | Kind | Hint
-------------------------------------+---------------------+-----------+--------------------------------------+----------------+------------------
550e8400-e29b-41d4-a716-446655440000 | 2026-01-20 10:30:00 | audit-log | e98bb95f-573d-8137-8008-252580aa456d | exception-page | Error description
abc12345-e29b-41d4-a716-446655440001 | 2026-01-20 10:29:00 | logging | - | error | Another error that is very long and ne...
More results: use --since 2026-01-20T10:28:00Z --since-id def45678-e29b-41d4-a716-446655440002
```
### JSON
Returns structured JSON with error details and pagination metadata:
```json
{
"items": [
{
"id": "uuid",
"createdAt": "2026-01-20T10:30:00Z",
"source": "audit-log",
"profileId": "e98bb95f-573d-8137-8008-252580aa456d",
"kind": "exception-page",
"tenant": "production",
"version": "2.1.0",
"hint": "Error description"
}
],
"nextSince": "2026-01-20T10:29:00Z",
"nextId": "next-uuid"
}
```
## Pagination
### Manual pagination
Use `--since` and `--since-id` with values from `nextSince` and `nextId` in the response:
```bash
./scripts/error-reports.mjs list --limit 50
# Use nextSince and nextId from response
./scripts/error-reports.mjs list --limit 50 --since "2026-01-20T10:29:00Z" --since-id "next-uuid"
```
### Automatic pagination
Use `--all` to fetch all pages automatically:
```bash
./scripts/error-reports.mjs list --all
```
## Key principles
- **Authentication required** - Uses access token with `error-reports:read` permission
- **API endpoint configurable** - Set via `PENPOT_API_URI` in `.env` file
- **Table is default format** - Use `--format json` for structured JSON output
- **Pagination is automatic with --all** - Fetches all pages without manual cursor management
- **Filters are combinable** - All filter options can be used together
- **Both flag formats supported** - `--option=value` and `--option value` both work
## Error handling
The tool provides helpful error messages for common issues:
- **Missing configuration**: Shows setup instructions for `.env` file
- **Authentication errors (401)**: Indicates invalid or expired token
- **Authorization errors (403)**: Indicates missing `error-reports:read` permission
- **RPC errors**: Displays error code and message from the API
## Integration with other scripts
- **jq**: Pipe JSON output to `jq` for further processing
```bash
./scripts/error-reports.mjs list --all --format json | jq '.items[] | {id, kind, hint}'
```
- **grep/search**: Filter output by specific patterns
- **Redirect**: Save output to files for analysis
```bash
./scripts/error-reports.mjs list --all --format json > errors.json
```

View File

@ -10,9 +10,10 @@ penpot - error list
<a href="/dbg"> [BACK]</a>
<h1>Error reports (last 300)</h1>
<a class="{% if version = 3 %}strong{% endif %}" href="?version=3">[BACKEND ERRORS]</a>
<a class="{% if version = 4 %}strong{% endif %}" href="?version=4">[FRONTEND ERRORS]</a>
<a class="{% if version = 5 %}strong{% endif %}" href="?version=5">[RLIMIT REPORTS]</a>
<a class="{% if source = 0 %}strong{% endif %}" href="?source=0">[ALL ERRORS]</a>
<a class="{% if source = 3 %}strong{% endif %}" href="?source=3">[BACKEND ERRORS]</a>
<a class="{% if source = 4 %}strong{% endif %}" href="?source=4">[FRONTEND ERRORS]</a>
<a class="{% if source = 5 %}strong{% endif %}" href="?source=5">[RLIMIT REPORTS]</a>
</div>
</nav>
<main class="horizontal-list">

View File

@ -6,7 +6,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v3)
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#props">props</a>]</div>
<div>[<a href="#context">context</a>]</div>

View File

@ -6,11 +6,11 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#context">context</a>]</div>
{% if report %}
<div>[<a href="#report">report</a>]</div>
{% if trace %}
<div>[<a href="#trace">trace</a>]</div>
{% endif %}
</nav>
<main>
@ -20,7 +20,7 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
<div class="table-val">
<h1><span class="not-important">Hint:</span> <br/> {{hint}}</h1>
<h2><span class="not-important">Reported at:</span> <br/> {{created-at}}</h2>
<h2><span class="not-important">Origin:</span> <br/> {{origin}}</h2>
<h2><span class="not-important">Kind:</span> <br/> {{kind}}</h2>
<h2><span class="not-important">HREF:</span> <br/> {{href}}</h2>
</div>
</div>
@ -33,11 +33,11 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Error Report (v4)
</div>
</div>
{% if report %}
{% if trace %}
<div class="table-row multiline">
<div id="report" class="table-key">REPORT:</div>
<div id="trace" class="table-key">TRACE:</div>
<div class="table-val">
<pre>{{report}}</pre>
<pre>{{trace}}</pre>
</div>
</div>
{% endif %}

View File

@ -6,10 +6,10 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
{% block content %}
<nav>
<div>[<a href="/dbg/error?version={{version}}">⮜</a>]</div>
<div>[<a href="/dbg/error?source={{source}}">⮜</a>]</div>
<div>[<a href="#head">head</a>]</div>
<div>[<a href="#context">context</a>]</div>
<div>[<a href="#result">result</a>]</div>
<div>[<a href="#value">value</a>]</div>
</nav>
<main>
<div class="table">
@ -30,9 +30,9 @@ Report: {{hint|abbreviate:150}} - {{id}} - Penpot Rate Limit Report
</div>
<div class="table-row multiline">
<div id="result" class="table-key">RESULT: </div>
<div id="value" class="table-key">VALUE: </div>
<div class="table-val">
<pre>{{result}}</pre>
<pre>{{value}}</pre>
</div>
</div>
</div>

View File

@ -43,7 +43,6 @@
(let [{:keys [type claims]} (get request ::http/auth-data)]
(if (= :token type)
(let [{:keys [perms profile-id expires-at]} (some->> claims (get-token-data pool))]
;; FIXME: revisit this, this data looks unused
(handler (cond-> request
(some? perms)
(assoc ::perms perms)

View File

@ -230,25 +230,28 @@
(-> (io/resource "app/templates/error-report.v3.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 3)
(assoc :source 3)
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
(render-template-v4 [{:keys [content id created-at]}]
(-> (io/resource "app/templates/error-report.v4.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 4)
(assoc :source 4)
(assoc :kind (or (:kind content) (:origin content)))
(assoc :trace (or (:trace content) (:report content)))
(assoc :created-at (ct/format-inst created-at :rfc1123))))))
(render-template-v5 [{:keys [content id created-at]}]
(-> (io/resource "app/templates/error-report.v5.tmpl")
(tmpl/render (-> content
(assoc :id id)
(assoc :version 5)
(assoc :source 5)
(assoc :value (or (:value content) (:result content)))
(assoc :created-at (ct/format-inst created-at :rfc1123))))))]
(if-let [report (get-report request)]
(let [result (case (:version report)
(let [result (case (:source report)
1 (render-template-v1 report)
2 (render-template-v2 report)
3 (render-template-v3 report)
@ -265,18 +268,19 @@
"SELECT id, created_at,
content->>'~:hint' AS hint
FROM server_error_report
WHERE version = ?
ORDER BY created_at DESC
LIMIT 300")
WHERE (version = ? OR source = ? OR ? = 0)
ORDER BY created_at DESC
LIMIT 300")
(defn- error-list-handler
[{:keys [::db/pool]} {:keys [params]}]
(let [version (or (some-> (get params :version) parse-long) 3)
items (->> (db/exec! pool [sql:error-reports version])
(map #(update % :created-at ct/format-inst :rfc1123)))]
(let [source (or (some-> (get params :source) parse-long) 3)
items (->> (db/exec! pool [sql:error-reports source source source])
(map #(update % :created-at ct/format-inst :rfc1123)))]
{::yres/status 200
::yres/body (-> (io/resource "app/templates/error-list.tmpl")
(tmpl/render {:items items :version version}))
(tmpl/render {:items items :source source}))
::yres/headers {"content-type" "text/html; charset=utf-8"
"x-robots-tag" "noindex"}}))

View File

@ -12,6 +12,7 @@
[app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.schema :as sm]
[app.common.uri :as u]
[app.config :as cf]
[app.db :as db]
[app.loggers.audit :as audit]
@ -30,11 +31,12 @@
(defonce enabled (atom true))
(defn- persist-on-database!
[pool id version report]
[pool id source report]
(when-not (db/read-only? pool)
(db/insert! pool :server-error-report
{:id id
:version version
:source source
:version source ;; backward compatibility with old code that reads version column
:content (db/tjson report)})))
(defn- concurrent-exception?
@ -56,19 +58,27 @@
(assoc :backend/version (:full cf/version))
(assoc :logger/name logger)
(assoc :logger/level level)
(dissoc :request/params :value :params :data))]
(dissoc :request/params :value :params :data))
href (if-let [path (:request/path context)]
(str (u/join (cf/get :public-uri) path))
(str (cf/get :public-uri)))]
(merge
{:context (-> (into (sorted-map) ctx)
(pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50)
:hint (or (when-let [message (ex-message cause)]
(if-let [props-hint (:hint props)]
(str props-hint ": " message)
message))
@message)
:trace (or (::trace record)
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))}
{:context (-> (into (sorted-map) ctx)
(pp/pprint-str :length 50))
:props (pp/pprint-str props :length 50)
:hint (or (when-let [message (ex-message cause)]
(if-let [props-hint (:hint props)]
(str props-hint ": " message)
message))
@message)
:trace (or (::trace record)
(some-> cause (ex/format-throwable :data? true :explain? false :header? false :summary? false)))
:tenant (cf/get :tenant)
:version (:full cf/version)
:profile-id (some-> (:request/profile-id context) str)
:href href}
(when-let [params (or (:request/params context) (:params context))]
{:params (pp/pprint-str params :length 20 :level 20)})
@ -97,7 +107,7 @@
(l/warn :hint "unexpected exception on database error logger" :cause cause))))
(defn- audit-event->report
[{:keys [context props ip-addr] :as record}]
[{:keys [context props ip-addr profile-id] :as record}]
(let [context
(reduce-kv (fn [context k v]
(let [k' (keyword "frontend" (name k))]
@ -115,12 +125,15 @@
(assoc :backend/version (:full cf/version))
(assoc :frontend/ip-addr ip-addr))]
{:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:origin (:name record)
:href (get props :href)
:hint (get props :hint)
:report (get props :report)}))
{:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:kind (:name record)
:profile-id (some-> profile-id str)
:href (get props :href)
:hint (get props :hint)
:trace (get props :report)
:tenant (cf/get :tenant)
:version (:full cf/version)}))
(defn- handle-audit-event
"Convert the log record into a report object and persist it on the database"
@ -153,10 +166,13 @@
(-> (into (sorted-map) result)
(dissoc ::rlimit/method)))))]
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:result (pp/pprint-str result :length 50)}))
{:hint (str "Rate Limit Rejection: " (::rlimit/method event) " for " (::rlimit/uid event))
:context (-> (into (sorted-map) context)
(pp/pprint-str :length 50))
:value (pp/pprint-str result :length 50)
:tenant (cf/get :tenant)
:version (:full cf/version)
:href (str (cf/get :public-uri))}))
(defn- handle-rlimit-event
"Convert the log record into a report object and persist it on the database"

View File

@ -680,7 +680,7 @@
(if-let [sns (namespace o)]
(do (require (symbol sns))
(test/test-vars [(resolve o)]))
(test/test-ns o)))))
(test/test-ns o)))))
(defn -main
[& _args]

View File

@ -493,7 +493,10 @@
:fn (mg/resource "app/migrations/sql/0150-mod-storage-object-table.sql")}
{:name "0151-mod-file-tagged-object-thumbnail-table"
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}])
:fn (mg/resource "app/migrations/sql/0151-mod-file-tagged-object-thumbnail-table.sql")}
{:name "0152-rename-version-and-add-indexes-to-server-error-report"
:fn (mg/resource "app/migrations/sql/0152-rename-version-and-add-indexes-to-server-error-report.sql")}])
(defn apply-migrations!
[pool name migrations]

View File

@ -0,0 +1,44 @@
-- Add source column (keep version column as-is for backward compatibility)
ALTER TABLE server_error_report
ADD COLUMN source integer;
-- Trigger function to sync version -> source (backward compatibility with old code)
CREATE OR REPLACE FUNCTION server_error_report__sync_version_to_source()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.version IS NOT NULL AND NEW.source IS NULL THEN
NEW.source := NEW.version;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger fires on INSERT or UPDATE OF version column
CREATE TRIGGER server_error_report__sync_version_to_source__tgr
BEFORE INSERT OR UPDATE OF version ON server_error_report
FOR EACH ROW
EXECUTE FUNCTION server_error_report__sync_version_to_source();
-- Backfill existing rows
UPDATE server_error_report SET source = version WHERE source IS NULL;
-- Drop old version index
DROP INDEX IF EXISTS server_error_report__version__idx;
-- Create new source index
CREATE INDEX server_error_report__source__idx
ON server_error_report (source);
-- Content-based indexes
CREATE INDEX server_error_report__content_kind__idx
ON server_error_report (COALESCE(content->>'~:kind', content->>'~:origin'));
CREATE INDEX server_error_report__content_tenant__idx
ON server_error_report ((content->>'~:tenant'));
CREATE INDEX server_error_report__content_version__idx
ON server_error_report ((content->>'~:version'));
-- Index for pagination
CREATE INDEX server_error_report__created_at_id__idx
ON server_error_report (created_at DESC, id DESC);

View File

@ -41,6 +41,7 @@
[app.util.cache :as cache]
[app.util.inet :as inet]
[app.util.services :as sv]
[clojure.set :as set]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
@ -102,8 +103,10 @@
session-id (yreq/get-header request "x-session-id")
key-id (get request ::http/auth-key-id)
profile-id (or (::session/profile-id request)
(::actoken/profile-id request)
session-pid (::session/profile-id request)
token-pid (::actoken/profile-id request)
profile-id (or session-pid
token-pid
(if key-id uuid/zero nil))
ip-addr (inet/parse-request request)
@ -116,7 +119,15 @@
(assoc ::session-id (some-> session-id uuid/parse*))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))
(assoc ::profile-id profile-id))
(cond-> (uuid? session-pid)
(assoc ::auth-type :session))
(cond-> (and (not (uuid? session-pid))
(uuid? token-pid))
(-> (assoc ::auth-type :token)
(assoc ::token-perms (set (::actoken/perms request #{})))))
(cond-> key-id
(assoc ::auth-key-id key-id)))
data (with-meta data
{::http/request request})
@ -151,13 +162,40 @@
(defn- wrap-authentication
[_ f mdata]
(fn [cfg params]
(let [profile-id (::profile-id params)]
(if (and (::auth mdata true) (not (uuid? profile-id)))
(ex/raise :type :authentication
:code :authentication-required
:hint "authentication required for this endpoint")
(f cfg params)))))
(let [required-auth? (::auth mdata true)
required-auth-type (::auth-type mdata)
required-perms (into #{} (::perms mdata))]
(fn [cfg params]
(let [profile-id (::profile-id params)
auth-type (::auth-type params)
token-perms (set (::token-perms params #{}))]
(cond
(and required-auth? (not (uuid? profile-id)))
(ex/raise :type :authentication
:code :authentication-required
:hint "authentication required for this endpoint")
(and (= required-auth-type :token)
(not= auth-type :token))
(ex/raise :type :authorization
:code :token-auth-required
:hint "access token authentication required for this endpoint")
(and (seq required-perms)
(not= auth-type :token))
(ex/raise :type :authorization
:code :token-auth-required
:hint "access token authentication required for this endpoint")
(and (seq required-perms)
(not (set/subset? required-perms token-perms)))
(ex/raise :type :authorization
:code :missing-perms
:hint "missing required permissions"
:required required-perms)
:else
(f cfg params))))))
(defn- wrap-db-transaction
[_ f mdata]
@ -332,6 +370,7 @@
'app.rpc.commands.binfile
'app.rpc.commands.comments
'app.rpc.commands.demo
'app.rpc.commands.error-reports
'app.rpc.commands.files
'app.rpc.commands.files-create
'app.rpc.commands.files-share

View File

@ -29,6 +29,10 @@
AND type = 'mcp'")
(defn create-access-token
"Create an access token with empty perms.
Elevated permissions (e.g. error-reports:read) are not assignable via
the public API; grant them with SQL or `repl:grant-access-token-perm`."
[{:keys [::db/conn] :as cfg} profile-id name expiration type]
(let [token-id (uuid/next)
expires-at (some-> expiration (ct/in-future))
@ -61,6 +65,27 @@
[cfg profile-id name expiration]
(db/tx-run! cfg create-access-token profile-id name expiration))
(def ^:private sql:grant-access-token-perm
"UPDATE access_token
SET perms = (
SELECT ARRAY(
SELECT DISTINCT unnest(perms || ARRAY[?]::text[])
)
),
updated_at = now()
WHERE id = ?
RETURNING id, perms")
(defn repl:grant-access-token-perm
"Append a permission string to an access token (operator/SQL path).
Example: (repl:grant-access-token-perm cfg token-id \"error-reports:read\")"
[cfg token-id perm]
(db/tx-run! cfg
(fn [{:keys [::db/conn]}]
(let [row (db/exec-one! conn [sql:grant-access-token-perm perm token-id])]
(some-> row (update :perms db/decode-pgarray #{}))))))
(def ^:private schema:create-access-token
[:map {:title "create-access-token"}
[:name [:string {:max 250 :min 1}]]

View File

@ -0,0 +1,180 @@
;; 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.rpc.commands.error-reports
"RPC methods for listing and fetching server error reports.
Access is restricted to access-token authentication with the
`error-reports:read` permission. Grant via SQL (or REPL helper):
UPDATE access_token
SET perms = ARRAY['error-reports:read']::text[],
updated_at = now()
WHERE id = '<token-uuid>';
Call with: Authorization: Token <jwt>"
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.doc :as doc]
[app.util.services :as sv]
[cuerdas.core :as str]))
(def ^:private max-limit 200)
(def ^:private default-limit 50)
(def ^:private source-names
{1 "legacy-v1"
2 "legacy-v2"
3 "logging"
4 "audit-log"
5 "rlimit"})
(defn- source->name
[source]
(get source-names source (str "unknown-" source)))
(defn- name->source
[name]
(some (fn [[k v]] (when (= v name) k)) source-names))
(def ^:private schema:error-report-summary
[:map
[:id ::sm/uuid]
[:created-at ct/schema:inst]
[:source ::sm/text]
[:profile-id {:optional true} ::sm/text]
[:kind {:optional true} ::sm/text]
[:tenant {:optional true} ::sm/text]
[:version {:optional true} ::sm/text]
[:hint {:optional true} ::sm/text]])
(def ^:private schema:get-error-reports-params
[:map {:title "get-error-reports-params"}
[:since {:optional true} ct/schema:inst]
[:since-id {:optional true} ::sm/uuid]
[:limit {:optional true}
[:and ::sm/int [:fn #(<= 1 % max-limit)]]]
[:source {:optional true} ::sm/text]
[:profile-id {:optional true} ::sm/text]
[:kind {:optional true} ::sm/text]
[:tenant {:optional true} ::sm/text]
[:version {:optional true} ::sm/text]
[:hint {:optional true} ::sm/text]])
(def ^:private schema:get-error-reports-result
[:map
[:items [:vector schema:error-report-summary]]
[:next-since {:optional true} ct/schema:inst]
[:next-id {:optional true} ::sm/uuid]])
(def ^:private schema:error-report
[:map
[:id ::sm/uuid]
[:created-at ct/schema:inst]
[:source ::sm/text]
[:profile-id {:optional true} ::sm/text]
[:kind {:optional true} ::sm/text]
[:tenant {:optional true} ::sm/text]
[:version {:optional true} ::sm/text]
[:hint {:optional true} ::sm/text]
[:report {:optional true} ::sm/text]
[:href {:optional true} ::sm/text]
[:context {:optional true} ::sm/text]])
(def ^:private schema:get-error-report-params
[:map
[:id ::sm/uuid]])
(def ^:private base-list-sql
(str "SELECT id, created_at, source, "
"COALESCE(content->>'~:kind', content->>'~:origin') AS kind, "
"content->>'~:tenant' AS tenant, "
"content->>'~:version' AS version, "
"content->>'~:hint' AS hint, "
"content->>'~:profile-id' AS profile_id "
"FROM server_error_report"))
(defn- build-list-query
[{:keys [since since-id source profile-id kind tenant version hint limit]
:or {limit default-limit}}]
(let [source-id (when source (name->source source))
clauses (keep identity
[(when source-id
{:where "source = ?" :params [source-id]})
(when profile-id
{:where "content->>'~:profile-id' = ?"
:params [profile-id]})
(when kind
{:where "COALESCE(content->>'~:kind', content->>'~:origin') = ?"
:params [kind]})
(when tenant
{:where "content->>'~:tenant' = ?"
:params [tenant]})
(when version
{:where "content->>'~:version' = ?"
:params [version]})
(when hint
{:where "content->>'~:hint' ILIKE ?"
:params [(str "%" hint "%")]})
(when since
{:where "(created_at, id) < (?::timestamptz, ?::uuid)"
:params [since (or since-id uuid/zero)]})])
sql-parts (map :where clauses)
sql-params (mapcat :params clauses)
sql (str base-list-sql
(when (seq sql-parts)
(str " WHERE " (str/join " AND " sql-parts)))
" ORDER BY created_at DESC, id DESC"
" LIMIT ?")]
(into [sql] (concat sql-params [limit]))))
(sv/defmethod ::get-error-reports
{::doc/added "2.20"
::rpc/auth-type :token
::rpc/perms #{"error-reports:read"}
::sm/params schema:get-error-reports-params
::sm/result schema:get-error-reports-result}
[cfg params]
(let [limit (min (or (:limit params) default-limit) max-limit)
params (assoc params :limit (inc limit))
[sql & sql-args] (build-list-query params)
rows (db/exec! cfg (into [sql] sql-args))]
(if (seq rows)
(let [items (->> (take limit rows)
(mapv #(-> %
(update :source source->name)
d/without-nils)))
last-item (peek items)
has-more? (> (count rows) limit)]
{:items items
:next-since (when has-more? (:created-at last-item))
:next-id (when has-more? (:id last-item))})
{:items []})))
(sv/defmethod ::get-error-report
{::doc/added "2.20"
::rpc/auth-type :token
::rpc/perms #{"error-reports:read"}
::sm/params schema:get-error-report-params
::sm/result schema:error-report}
[cfg {:keys [id]}]
(if-let [report (db/get-by-id cfg :server-error-report id {::db/check-deleted false})]
(let [content (db/decode-transit-pgobject (:content report))]
(-> report
(dissoc :content)
(merge content)
(update :source source->name)
(assoc :kind (or (:kind content) (:origin content)))
(d/without-nils)))
(ex/raise :type :not-found
:code :report-not-found
:hint (str "error report " id " not found"))))

View File

@ -142,7 +142,7 @@
(ig/init-key key opts)))
(defmethod ig/suspend-key! ::urepl
[_ name]
[_ _]
(l/inf :hint "keep urepl server"))
;; --- PREPL
@ -180,7 +180,7 @@
(ig/init-key key opts)))
(defmethod ig/suspend-key! ::prepl
[_ name]
[_ _]
(l/inf :hint "keep prepl server"))
;; --- NREPL
@ -209,5 +209,5 @@
(ig/init-key key opts)))
(defmethod ig/suspend-key! ::nrepl
[_ server]
[_ _]
(l/inf :hint "keep nrepl server"))

View File

@ -536,12 +536,12 @@
[id]
(let [id (h/parse-uuid id)]
(db/tx-run! sys/system (fn [cfg]
(-> (db/update! cfg :file
{:has-media-trimmed true}
{:id id}
{::db/return-keys false})
(db/get-update-count)
(pos?))))))
(-> (db/update! cfg :file
{:has-media-trimmed true}
{:id id}
{::db/return-keys false})
(db/get-update-count)
(pos?))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; DELETE/RESTORE OBJECTS (WITH CASCADE, SOFT)

View File

@ -29,8 +29,7 @@
(t/testing "create access token without expiration date"
(let [params {::th/type :create-access-token
::rpc/profile-id (:id prof)
:name "token 1"
:perms ["get-profile"]}
:name "token 1"}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
@ -40,13 +39,25 @@
(t/is (contains? result :id))
(t/is (contains? result :created-at))
(t/is (contains? result :updated-at))
(t/is (contains? result :token)))))
(t/is (contains? result :token))
(t/is (not (contains? result :perms))))))
(t/testing "create access token ignores client-supplied perms"
(let [params {::th/type :create-access-token
::rpc/profile-id (:id prof)
:name "token ignored-perms"
:perms ["error-reports:read"]}
out (th/command! params)]
(t/is (nil? (:error out)))
(let [result (:result out)
row (th/db-get :access-token {:id (:id result)})]
(t/is (not (contains? result :perms)))
(t/is (= [] (db/decode-pgarray (:perms row) []))))))
(t/testing "create access token with expiration date in the future"
(let [params {::th/type :create-access-token
::rpc/profile-id (:id prof)
:name "token 1"
:perms ["get-profile"]
:expiration "130h"}
out (th/command! params)]
;; (th/print-result! out)
@ -64,7 +75,6 @@
(let [params {::th/type :create-access-token
::rpc/profile-id (:id prof)
:name "token 1"
:perms ["get-profile"]
:expiration "-130h"}
out (th/command! params)]
;; (th/print-result! out)
@ -85,11 +95,12 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [[result :as results] (:result out)]
(t/is (= 3 (count results)))
(t/is (= 4 (count results)))
(t/is (contains? result :id))
(t/is (contains? result :created-at))
(t/is (contains? result :updated-at))
(t/is (not (contains? result :token))))))
(t/is (not (contains? result :token)))
(t/is (not (contains? result :perms))))))
(t/testing "delete access token"
(let [params {::th/type :delete-access-token
@ -107,14 +118,13 @@
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [results (:result out)]
(t/is (= 2 (count results))))))
(t/is (= 3 (count results))))))
(t/testing "get mcp token"
(let [_ (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "token 1"
:perms ["get-profile"]})
:name "token 1"})
{:keys [error result]}
(th/command! {::th/type :get-current-mcp-token
::rpc/profile-id (:id prof)})]
@ -126,16 +136,14 @@
(let [;; Create a regular token
regular-out (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:name "regular token"
:perms ["get-profile"]})
:name "regular token"})
regular-token (:result regular-out)
;; Create an MCP token
mcp-out (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "mcp token"
:perms []})
:name "mcp token"})
mcp-token (:result mcp-out)
;; Fetch all tokens
@ -163,24 +171,21 @@
first-out (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "first mcp"
:perms []})
:name "first mcp"})
first-mcp (:result first-out)
;; Create second MCP token
second-out (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "second mcp"
:perms []})
:name "second mcp"})
second-mcp (:result second-out)
;; Create third MCP token
third-out (th/command! {::th/type :create-access-token
::rpc/profile-id (:id prof)
:type "mcp"
:name "third mcp"
:perms []})
:name "third mcp"})
third-mcp (:result third-out)
;; Fetch all tokens

View File

@ -0,0 +1,251 @@
;; 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 Espana SL
(ns backend-tests.rpc-commands-error-reports-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.db :as db]
[app.rpc :as-alias rpc]
[backend-tests.helpers :as th]
[clojure.test :as t]
[cuerdas.core :as str]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(def ^:private error-reports-read #{"error-reports:read"})
(defn- insert-report!
"Insert a raw error report row. content is a plain map (will be tjson-encoded)."
[sys {:keys [id source content created-at]
:or {created-at (ct/now)}}]
(th/db-insert! :server-error-report
{:id (or id (uuid/next))
:source source
:created-at created-at
:content (db/tjson content)}))
(defn- token-cmd
"Invoke an RPC method as an access token with the given perms."
[profile params & {:keys [perms] :or {perms error-reports-read}}]
(th/command! (assoc params
::rpc/profile-id (:id profile)
::rpc/auth-type :token
::rpc/token-perms perms)))
(defn- session-cmd
"Invoke an RPC method as a session-authenticated profile."
[profile params]
(th/command! (assoc params
::rpc/profile-id (:id profile)
::rpc/auth-type :session)))
;; --- Auth tests
(t/deftest get-error-reports-requires-authentication
(let [out (th/command! {::th/type :get-error-reports})]
(t/is (not (th/success? out)))
(t/is (= :authentication (th/ex-type (:error out))))
(t/is (= :authentication-required (th/ex-code (:error out))))))
(t/deftest get-error-reports-requires-token-auth
(let [profile (th/create-profile* 1 {:is-active true})
out (session-cmd profile {::th/type :get-error-reports})]
(t/is (not (th/success? out)))
(t/is (= :authorization (th/ex-type (:error out))))
(t/is (= :token-auth-required (th/ex-code (:error out))))))
(t/deftest get-error-reports-requires-perm
(let [profile (th/create-profile* 1 {:is-active true})
out (token-cmd profile {::th/type :get-error-reports} :perms #{})]
(t/is (not (th/success? out)))
(t/is (= :authorization (th/ex-type (:error out))))
(t/is (= :missing-perms (th/ex-code (:error out))))))
(t/deftest get-error-reports-allows-token-with-perm
(let [profile (th/create-profile* 1 {:is-active true})
out (token-cmd profile {::th/type :get-error-reports})]
(t/is (th/success? out))
(t/is (vector? (:items (:result out))))
(t/is (zero? (count (:items (:result out)))))))
;; --- List shape
(t/deftest get-error-reports-returns-summary-shape
(let [id (uuid/next)
profile (th/create-profile* 1 {:is-active true})]
(insert-report! th/*system*
{:id id
:source 3
:content {:hint "test error"
:tenant "devenv"
:version "2.20.0-devenv"}})
(let [out (token-cmd profile {::th/type :get-error-reports})
result (:result out)]
(t/is (th/success? out))
(t/is (vector? (:items result)))
(t/is (= 1 (count (:items result))))
(let [item (first (:items result))]
(t/is (= id (:id item)))
(t/is (= "logging" (:source item)))
(t/is (= "test error" (:hint item)))
(t/is (= "devenv" (:tenant item)))
(t/is (= "2.20.0-devenv" (:version item)))
(t/is (nil? (:content item)))
(t/is (nil? (:kind item)))))))
;; --- Filters
(t/deftest get-error-reports-filter-by-source
(let [profile (th/create-profile* 1 {:is-active true})]
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "backend error"}})
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "frontend error"}})
(insert-report! th/*system* {:id (uuid/next) :source 5 :content {:hint "rlimit error"}})
(let [out (token-cmd profile {::th/type :get-error-reports :source "audit-log"})]
(t/is (th/success? out))
(t/is (= 1 (count (:items (:result out)))))
(t/is (= "audit-log" (:source (first (:items (:result out)))))))))
(t/deftest get-error-reports-filter-by-kind
(let [profile (th/create-profile* 1 {:is-active true})]
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "page err" :kind "exception-page"}})
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "unhandled" :kind "unhandled-exception"}})
(let [out (token-cmd profile {::th/type :get-error-reports :kind "exception-page"})]
(t/is (th/success? out))
(t/is (= 1 (count (:items (:result out)))))
(t/is (= "exception-page" (:kind (first (:items (:result out)))))))))
(t/deftest get-error-reports-filter-by-tenant
(let [profile (th/create-profile* 1 {:is-active true})]
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "a" :tenant "tenant-a"}})
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "b" :tenant "tenant-b"}})
(let [out (token-cmd profile {::th/type :get-error-reports :tenant "tenant-a"})]
(t/is (th/success? out))
(t/is (= 1 (count (:items (:result out)))))
(t/is (= "tenant-a" (:tenant (first (:items (:result out)))))))))
(t/deftest get-error-reports-filter-by-hint-ilike
(let [profile (th/create-profile* 1 {:is-active true})]
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "NullPointerException in render"}})
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "Timeout connecting to DB"}})
(let [out (token-cmd profile {::th/type :get-error-reports :hint "null"})]
(t/is (th/success? out))
(t/is (= 1 (count (:items (:result out)))))
(t/is (str/includes? (:hint (first (:items (:result out)))) "Null")))))
;; --- Kind normalization (old ~:origin vs new ~:kind)
(t/deftest get-error-reports-kind-normalization
(let [profile (th/create-profile* 1 {:is-active true})]
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "old shape" :origin "old-origin"}})
(insert-report! th/*system* {:id (uuid/next) :source 4 :content {:hint "new shape" :kind "new-kind"}})
;; Both should appear in unfiltered list
(let [out (token-cmd profile {::th/type :get-error-reports})]
(t/is (th/success? out))
(t/is (= 2 (count (:items (:result out))))))
;; Filtering by kind should match old ~:origin
(let [out (token-cmd profile {::th/type :get-error-reports :kind "old-origin"})]
(t/is (th/success? out))
(t/is (= 1 (count (:items (:result out)))))
(t/is (= "old shape" (:hint (first (:items (:result out)))))))
;; Filtering by kind should match new ~:kind
(let [out (token-cmd profile {::th/type :get-error-reports :kind "new-kind"})]
(t/is (th/success? out))
(t/is (= 1 (count (:items (:result out)))))
(t/is (= "new shape" (:hint (first (:items (:result out)))))))))
;; --- Pagination
(t/deftest get-error-reports-pagination
(let [profile (th/create-profile* 1 {:is-active true})
t1 (ct/in-past "10m")
t2 (ct/in-past "9m")
t3 (ct/in-past "8m")
t4 (ct/in-past "7m")]
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "1"} :created-at t1})
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "2"} :created-at t2})
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "3"} :created-at t3})
(insert-report! th/*system* {:id (uuid/next) :source 3 :content {:hint "4"} :created-at t4})
;; Page 1: newest 2
(let [out (token-cmd profile {::th/type :get-error-reports :limit 2})]
(t/is (th/success? out))
(let [{:keys [items next-since next-id]} (:result out)]
(t/is (= 2 (count items)))
(t/is (some? next-since))
(t/is (some? next-id))
(t/is (= "4" (:hint (first items))))
(t/is (= "3" (:hint (second items))))
;; Page 2: next 2, using since and since-id from page 1
(let [out2 (token-cmd profile {::th/type :get-error-reports :limit 2 :since next-since :since-id next-id})]
(t/is (th/success? out2))
(let [{:keys [items next-since]} (:result out2)]
(t/is (= 2 (count items)))
(t/is (nil? next-since))
(t/is (= "2" (:hint (first items))))
(t/is (= "1" (:hint (second items))))))))))
(t/deftest get-error-reports-pagination-same-timestamp
(let [profile (th/create-profile* 1 {:is-active true})
t (ct/in-past "5m")
id1 (uuid/next)
id2 (uuid/next)
id3 (uuid/next)]
(insert-report! th/*system* {:id id1 :source 3 :content {:hint "1"} :created-at t})
(insert-report! th/*system* {:id id2 :source 3 :content {:hint "2"} :created-at t})
(insert-report! th/*system* {:id id3 :source 3 :content {:hint "3"} :created-at t})
;; Page 1: first 2 of the 3 same-timestamp rows
(let [out (token-cmd profile {::th/type :get-error-reports :limit 2})]
(t/is (th/success? out))
(let [{:keys [items next-since next-id]} (:result out)]
(t/is (= 2 (count items)))
(t/is (some? next-since))
(t/is (some? next-id))
;; Page 2: should return the remaining row, not skip it
(let [out2 (token-cmd profile {::th/type :get-error-reports :limit 2 :since next-since :since-id next-id})]
(t/is (th/success? out2))
(let [{:keys [items next-since]} (:result out2)]
(t/is (= 1 (count items)))
(t/is (nil? next-since))))))))
;; --- Single fetch
(t/deftest get-error-report-requires-token-auth
(let [profile (th/create-profile* 1 {:is-active true})
id (uuid/next)]
(insert-report! th/*system* {:id id :source 3 :content {:hint "single report" :tenant "devenv"}})
(let [out (session-cmd profile {::th/type :get-error-report :id id})]
(t/is (not (th/success? out)))
(t/is (= :authorization (th/ex-type (:error out))))
(t/is (= :token-auth-required (th/ex-code (:error out)))))))
(t/deftest get-error-report-requires-perm
(let [profile (th/create-profile* 1 {:is-active true})
id (uuid/next)]
(insert-report! th/*system* {:id id :source 3 :content {:hint "single report"}})
(let [out (token-cmd profile {::th/type :get-error-report :id id} :perms #{})]
(t/is (not (th/success? out)))
(t/is (= :authorization (th/ex-type (:error out))))
(t/is (= :missing-perms (th/ex-code (:error out)))))))
(t/deftest get-error-report-success
(let [profile (th/create-profile* 1 {:is-active true})
id (uuid/next)]
(insert-report! th/*system* {:id id :source 3 :content {:hint "single report" :tenant "devenv"}})
(let [out (token-cmd profile {::th/type :get-error-report :id id})]
(t/is (th/success? out))
(let [result (:result out)]
(t/is (= id (:id result)))
(t/is (= "logging" (:source result)))
(t/is (= "single report" (:hint result)))
(t/is (= "devenv" (:tenant result)))))))
(t/deftest get-error-report-not-found
(let [profile (th/create-profile* 1 {:is-active true})
out (token-cmd profile {::th/type :get-error-report :id (uuid/next)})]
(t/is (not (th/success? out)))
(t/is (= :not-found (th/ex-type (:error out))))
(t/is (= :report-not-found (th/ex-code (:error out))))))

View File

@ -14,6 +14,8 @@
"@playwright/mcp": "^0.0.76",
"@playwright/test": "^1.61.1",
"@types/node": "^26.0.1",
"commander": "^14.0.1",
"dotenv": "^16.4.7",
"esbuild": "^0.28.1",
"mdts": "^0.20.3",
"nrepl-client": "^0.3.0",

12
pnpm-lock.yaml generated
View File

@ -17,6 +17,12 @@ importers:
'@types/node':
specifier: ^26.0.1
version: 26.0.1
commander:
specifier: ^14.0.1
version: 14.0.3
dotenv:
specifier: ^16.4.7
version: 16.6.1
esbuild:
specifier: ^0.28.1
version: 0.28.1
@ -319,6 +325,10 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@ -943,6 +953,8 @@ snapshots:
depd@2.0.0: {}
dotenv@16.6.1: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2

340
scripts/error-reports.mjs Executable file
View File

@ -0,0 +1,340 @@
#!/usr/bin/env node
import { Command } from "commander";
import dotenv from "dotenv";
import { readFileSync, existsSync } from "fs";
import path from "path";
// ============================================================================
// Configuration
// ============================================================================
function loadConfig(envPath) {
const envFile = envPath || ".env";
if (existsSync(envFile)) {
dotenv.config({ path: envFile });
} else if (envPath) {
console.error(`Error: .env file not found at ${envPath}`);
process.exit(1);
}
const config = {
apiUrl: process.env.PENPOT_API_URI,
accessToken: process.env.PENPOT_ACCESS_TOKEN
};
if (!config.apiUrl) {
console.error("Error: PENPOT_API_URI not set");
console.error("\nCreate a .env file with:");
console.error(" PENPOT_API_URI=http://localhost:3450");
console.error(" PENPOT_ACCESS_TOKEN=<your-token>");
process.exit(1);
}
if (!config.accessToken) {
console.error("Error: PENPOT_ACCESS_TOKEN not set");
console.error("\nCreate a .env file with:");
console.error(" PENPOT_API_URI=http://localhost:3450");
console.error(" PENPOT_ACCESS_TOKEN=<your-token>");
console.error("\nGrant permission to your token:");
console.error(" UPDATE access_token SET perms = ARRAY['error-reports:read']::text[]");
console.error(" WHERE id = '<token-uuid>';");
process.exit(1);
}
return config;
}
// ============================================================================
// RPC Client
// ============================================================================
async function rpcCall(config, method, params = {}) {
const url = `${config.apiUrl}/api/main/methods/${method}`;
let response;
try {
response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": `Token ${config.accessToken}`
},
body: JSON.stringify(params)
});
} catch (err) {
if (err.cause?.code === "ECONNREFUSED") {
console.error("Error: Cannot connect to server (connection refused)");
console.error("The Penpot backend server is not running or not reachable.");
console.error("\nCheck that:");
console.error(" 1. The backend server is running");
console.error(" 2. PENPOT_API_URI in .env points to the correct URL");
console.error(` Current URI: ${config.apiUrl}`);
process.exit(1);
}
if (err.cause?.code === "ENOTFOUND") {
console.error("Error: Cannot resolve server hostname");
console.error(`The hostname in PENPOT_API_URI is not valid: ${config.apiUrl}`);
process.exit(1);
}
throw new Error(`Network error: ${err.message}`);
}
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = { message: response.statusText };
}
if (response.status === 401) {
console.error("Error: Authentication failed (401)");
console.error("Your access token may be invalid or expired.");
process.exit(1);
}
if (response.status === 403) {
console.error("Error: Authorization failed (403)");
console.error("Your access token lacks the required permission: error-reports:read");
console.error("\nGrant permission:");
console.error(" UPDATE access_token SET perms = ARRAY['error-reports:read']::text[]");
console.error(" WHERE id = '<token-uuid>';");
process.exit(1);
}
if (response.status === 502) {
console.error("Error: Server is down (502 Bad Gateway)");
console.error("The Penpot backend server is not responding.");
console.error("\nCheck that:");
console.error(" 1. The backend server is running");
console.error(" 2. PENPOT_API_URI in .env points to the correct URL");
console.error(` Current URI: ${config.apiUrl}`);
process.exit(1);
}
if (response.status === 503) {
console.error("Error: Service unavailable (503)");
console.error("The Penpot backend server is temporarily unavailable.");
console.error("Please wait a moment and try again.");
process.exit(1);
}
if (response.status === 504) {
console.error("Error: Gateway timeout (504)");
console.error("The Penpot backend server did not respond in time.");
console.error("\nCheck that:");
console.error(" 1. The backend server is running and responsive");
console.error(" 2. PENPOT_API_URI in .env points to the correct URL");
console.error(` Current URI: ${config.apiUrl}`);
process.exit(1);
}
const code = errorData.code || "unknown";
const message = errorData.message || errorData.hint || "Unknown error";
throw new Error(`RPC error [${code}]: ${message}`);
}
return response.json();
}
// ============================================================================
// Output Formatting
// ============================================================================
function formatJson(data) {
return JSON.stringify(data, null, 2);
}
function formatTable(data, type) {
if (type === "list") {
return formatListTable(data);
} else if (type === "get") {
return formatGetTable(data);
}
return formatJson(data);
}
function formatListTable(data, hasMore = false) {
if (!data.items || data.items.length === 0) {
return "No error reports found.";
}
const lines = [];
lines.push(`Found ${data.items.length} error reports`);
lines.push("");
const headers = ["ID", "Created At", "Source", "Profile ID", "Kind", "Hint"];
const rows = data.items.map(item => [
item.id || "-",
item.createdAt || "-",
item.source || "-",
item.profileId || "-",
item.kind || "-",
truncateHint(item.hint, 60)
]);
const colWidths = headers.map((h, i) => {
const maxData = Math.max(...rows.map(r => (r[i] || "").toString().length));
return Math.max(h.length, maxData);
});
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(" | ");
const separator = colWidths.map(w => "-".repeat(w)).join("-+-");
const dataLines = rows.map(row =>
row.map((cell, i) => (cell || "-").toString().padEnd(colWidths[i])).join(" | ")
);
lines.push(headerLine);
lines.push(separator);
lines.push(...dataLines);
if (hasMore && data.nextSince && data.nextId) {
lines.push("");
lines.push(`More results: use --since ${data.nextSince} --since-id ${data.nextId}`);
}
return lines.join("\n");
}
function truncateHint(text, maxLength) {
if (!text) return "-";
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + "...";
}
function formatGetTable(data) {
const lines = [];
lines.push(`ID: ${data.id || "(none)"}`);
lines.push(`Created At: ${data.createdAt || "(none)"}`);
lines.push(`Source: ${data.source || "(none)"}`);
lines.push(`Profile ID: ${data.profileId || "(none)"}`);
lines.push(`Kind: ${data.kind || "(none)"}`);
lines.push(`Version: ${data.version || "(none)"}`);
lines.push(`Hint: ${data.hint || "(none)"}`);
lines.push(`HREF: ${data.href || "(none)"}`);
lines.push(`--- Report ---`);
if (data.report) {
const indentedReport = data.report.split("\n").map(line => ` ${line}`).join("\n");
lines.push(indentedReport);
} else {
lines.push(" (none)");
}
return lines.join("\n");
}
// ============================================================================
// Commands
// ============================================================================
async function cmdList(config, args) {
const params = {};
if (args.limit !== undefined) params.limit = args.limit;
if (args.since) params.since = args.since;
if (args.sinceId) params["since-id"] = args.sinceId;
if (args.source !== undefined) params.source = args.source;
if (args.profileId) params["profile-id"] = args.profileId;
if (args.kind) params.kind = args.kind;
if (args.tenant) params.tenant = args.tenant;
if (args.version) params.version = args.version;
if (args.hint) params.hint = args.hint;
let allItems = [];
let lastResult = null;
let hasMore = true;
let sinceParam = params.since;
let sinceIdParam = params["since-id"];
while (hasMore) {
const currentParams = { ...params };
if (sinceParam) currentParams.since = sinceParam;
if (sinceIdParam) currentParams["since-id"] = sinceIdParam;
lastResult = await rpcCall(config, "get-error-reports", currentParams);
allItems = [...allItems, ...lastResult.items];
if (args.all && lastResult.nextSince && lastResult.nextId) {
sinceParam = lastResult.nextSince;
sinceIdParam = lastResult.nextId;
} else {
hasMore = false;
}
}
const output = {
items: allItems
};
if (!args.all && lastResult) {
output.nextSince = lastResult.nextSince;
output.nextId = lastResult.nextId;
}
if (args.format === "table") {
const hasMore = !args.all && lastResult.nextSince && lastResult.nextId;
console.log(formatListTable(output, hasMore));
} else {
console.log(formatJson(output));
}
}
async function cmdGet(config, args) {
const params = args.id ? { id: args.id } : { id: args.errorId };
const result = await rpcCall(config, "get-error-report", params);
if (args.format === "table") {
console.log(formatTable(result, "get"));
} else {
console.log(formatJson(result));
}
}
// ============================================================================
// CLI Setup with Commander
// ============================================================================
const program = new Command();
program
.name("error-reports")
.description("Query Penpot error reports via RPC API")
.version("1.0.0");
program
.command("list")
.description("List error reports with pagination and filters")
.option("-l, --limit <n>", "Max items per page (default: 50, max: 200)", (value) => parseInt(value, 10), 50)
.option("--since <date>", "ISO timestamp (fetch errors before this date)")
.option("--since-id <uuid>", "Fetch errors before this ID (cursor pagination)")
.option("-s, --source <name>", "Filter by source (legacy-v1, legacy-v2, logging, audit-log, rlimit)")
.option("-p, --profile-id <uuid>", "Filter by profile ID")
.option("-k, --kind <kind>", "Filter by kind (string)")
.option("-t, --tenant <tenant>", "Filter by tenant (string)")
.option("--version <version>", "Filter by version")
.option("--hint <text>", "Filter by hint (ILIKE match)")
.option("-a, --all", "Fetch all pages automatically", false)
.option("-f, --format <type>", "Output format (json|table)", "table")
.option("--env <path>", "Custom .env file path")
.action(async (options) => {
const config = loadConfig(options.env);
await cmdList(config, options);
});
program
.command("get")
.description("Get a single error report by ID")
.requiredOption("--id <uuid>", "Error report ID")
.option("--error-id <id>", "Error report error-id")
.option("-f, --format <type>", "Output format (json|table)", "table")
.option("--env <path>", "Custom .env file path")
.action(async (options) => {
const config = loadConfig(options.env);
await cmdGet(config, options);
});
program.parse();