Wire the conversion helpers into every rich-text handler: read/list/echo
convert HTML to markdown, create/update convert markdown to HTML before
persisting, and each op documents the format query field. Opt-in via
?format=markdown or the X-Vikunja-Format header.
Adds the opt-in format plumbing for v2: requestWantsMarkdown (query or
X-Vikunja-Format header), convertToMarkdown/convertToHTML/convertTasksToMarkdown
field converters, the cross-cutting API description, and stripPatchFormatQuery
(AutoPatch drops the query, so PATCH advertises only the header).
Incoming CalDAV descriptions are markdown; convert them back to canonical HTML
(rebuilding mentions) before persisting. Skip the conversion when the markdown
is unchanged from the stored HTML so a passthrough sync doesn't churn the value
or bump the ETag.
CalDAV clients render DESCRIPTION as plain text, so convert the stored HTML to
markdown when serializing VTODOs. On the near-impossible conversion error, log
it and keep the stored value.
Converts GFM Markdown to canonical HTML via goldmark (GFM, no WithUnsafe),
rewriting task lists into TipTap's structure and resolving @username mentions
to <mention-user> tags against real users.
Converts rich-text HTML to GFM Markdown (standard + GFM extensions) including
the Vikunja/TipTap-specific nodes (mentions, task lists). Adds the
html-to-markdown/v2 dependency.
The api-token permission group key is derived from the route slug. Every
group is snake_case except "time-entries", whose URL slug carries a hyphen.
The frontend snake_cases request payloads, rewriting that group key to
"time_entries", which the backend then rejected — so a token granted the
Time Entries scope could not be saved.
Canonicalise group and path-segment names to snake_case where they are
derived, and normalise the group key on token validation and authorisation
so any token stored under the old hyphenated key keeps resolving. No data
migration is needed: the v2 time-entries resource has never shipped in a
release.
Set the just-logged-out flag before navigating, and skip the intermediate
router.push to login when redirecting to the IdP — otherwise Login.vue's
onBeforeMount consumed the flag before the logout round-trip landed, so the
single-provider auto-redirect fired and logged the user straight back in.
redirectToProviderOnLogout now reports whether it navigated, so logout can fall through to the login page when there's no static logout URL.
On X11/XWayland (Electron's default on Wayland sessions) the window had no
icon, so KDE Plasma showed the generic placeholder. Point BrowserWindow at
the packaged icon.png so the compositor has an icon to render.
When the app is hidden in the tray, closing then relaunching it triggered
the single-instance second-instance handler, which only called focus() — a
hidden window stays hidden on focus(), so the app appeared not to start
(notably on KDE Plasma Wayland where the tray icon may also be unreachable).
Call show() to surface it, and recreate the window if it no longer exists.
The desktop app ignored termination signals because the tray and embedded
express server keep the Electron event loop alive, forcing users to kill -9
on logout/shutdown. Add SIGINT/SIGTERM handlers that set isQuitting before
app.quit() so the hide-to-tray close handler doesn't swallow the quit.
node-gyp's undici 6.26.0 -> 6.27.0 and @electron/get's 7.27.2 -> 7.28.0,
each pinned within its major via overrides so only the security patch is
taken (an open >= range would jump across majors, e.g. to undici 8).
Resolves the 9 open desktop undici Dependabot alerts.
Newer devenv generates the devcontainer config at .devcontainer/devcontainer.json
instead of the legacy root-level .devcontainer.json, which left an untracked
duplicate. Track the new path so regeneration is a no-op.
Marking a task done via the list-view checkbox deferred the entire update
— including the network request — by 300ms to play the check animation. If
the page was torn down within that window (a refresh, tab close, or leaving
the app), the request was never sent and the save was silently lost. For
repeating tasks this is especially confusing: the due date never advances,
yet the checkbox un-checks itself anyway, so the failure looks identical to
success.
Fire the request immediately with the intended done value snapshotted, and
defer only the animation-coupled follow-up (result swap, pop sound, toast).
The optimistic v-model state already drives the check animation during the
300ms, so nothing visual is lost.
The v1 update was a whole-object POST /tasks/{id}: omitted scalars were
zeroed, so a status-only `veans update` silently wiped a task's
description and priority. The v1->v2 migration replaced that with
PATCH /tasks/{id} carrying a JSON Merge Patch built from only the
changed fields (client.TaskPatch, all-pointer + omitempty), which fixes
this by construction — absent fields are left untouched server-side.
Pin it with the acceptance tests from the issue: a title-only and a
status-only update must send only the field(s) they change, so the
stored description and priority survive.
veans is unreleased and targets bleeding-edge Vikunja, so the CLI now
speaks the Huma-backed /api/v2 exclusively (v1 is frozen and the kanban
bucket CRUD veans relies on only exists on v2).
- Transport: base path /api/v1 -> /api/v2 in Do/DoRaw; add a
content-type-aware path (DoMerge for application/merge-patch+json).
- Pagination: drop the x-pagination-total-pages header reader; every v2
list returns the {items,total,page,per_page,total_pages} envelope.
Decode it with a generic Paginated[T]/doList[T] and page until
page >= total_pages. Previously-single-GET lists (views, buckets,
comments, bots) are enveloped too — unwrap .items.
- Verbs: creates flip PUT -> POST (projects, labels, tokens, bot users,
shares, task create, comments, relations, assignees, label-attach,
bucket create); the bucket-task move flips POST -> PUT with a bare
{"task_id":N} body (URL owns project/view/bucket); task update moves
to PATCH merge-patch with a partial body.
- Errors: parse the RFC 9457 problem+json body (detail/title/code)
instead of v1's {code,message}; the status -> output.Code mapping is
unchanged.
- Discovery probes /api/v2/info, which doubles as the "new enough" check.
- Label search param s -> q; add views_buckets_tasks_put to the bot's
projects scope so the move is authorized regardless of route-init order.
Tests and the veans agent guide are updated for the new paths, verbs and
envelope. Verified end-to-end against a local v2 server: init, create,
show, list, claim, update and prime all work.
Port the standalone bucket list/create/update/delete from v1 to the
Huma-backed /api/v2, under /projects/{project}/views/{view}/buckets,
using v2 verb conventions (POST creates, PUT updates). The handlers
reuse the generic handler.Do* functions, so permissions are enforced
by the Bucket model's existing Can* methods.
Mirrors v1: no read-one route (the model has no ReadOne/CanRead), so
AutoPatch synthesises no PATCH. No model changes.
The tasks.read_all special case in CanDoAPIRoute only covered v1 paths.
Both GET /api/v2/tasks and GET /api/v2/projects/:project/tasks normalize
to the same tasks.read_all map key, but only one RouteDetail survives —
the project-scoped path overwrites the global one. The exact path
comparison then rejects the global endpoint with 401.
Extend the special case to include the v2 paths.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review point (#2950, comment 3444116036): when the surrounding task
<dialog> closed while the link prompt was open, the prompt was orphaned
and cleanup() never ran, leaking listeners and an unresolved promise.
Treat the prompt as a sub-modal of the task dialog: pressing Escape while
it is open now preventDefault()/stopPropagation()s the keydown so the
native modal <dialog> does not close on Escape, resolves the prompt with
'' (cancel) and runs cleanup() — only the prompt is dismissed, the task
dialog stays open. A one-shot 'cancel' listener on the enclosing dialog
backs this up in case the keydown handling is insufficient in some browser.
Tighten cleanup() so the prompt fully tears down regardless of how it
closes (Enter / Escape / click-outside): it now removes the scroll
listener, the document click listener and the dialog cancel listener, and
removes the element. handleClickOutside was hoisted so cleanup() can
remove it, closing the leaked-listener gap directly.
Adds an e2e asserting Escape cancels the prompt while the task dialog
stays open; the existing 'Enter creates the link' case still passes.
The Kanban task popup renders the description editor inside a native
<dialog> opened via showModal(), which lives in the browser's top-layer.
inputPrompt appended its URL <input> to document.body, so it was painted
behind the top-layer dialog (z-index cannot beat the top-layer) and could
not be focused through the dialog's focus trap. As a result clicking "Link"
in the popup did nothing, while it worked on the full task page (no modal).
Thread the TipTap editor through inputPrompt and append the prompt to
getPopupContainer(editor) — the open dialog ancestor when present, falling
back to document.body otherwise, so non-modal usage is unchanged. This is
the same helper the slash menu and mentions already use to escape the
top-layer (#1746).
Fixes#2940
The Kanban task detail opens as a native <dialog> via showModal(), which
paints in the browser top-layer. Floating UI appended to document.body
(or teleported to <body>) then renders behind the dialog regardless of
z-index, matching the bug class of #2940 / #1746 / #1899 / #1929.
- Emoji autocomplete popup: append to getPopupContainer(editor) (the open
dialog ancestor, else body), the same helper the slash menu and mentions
already use. Also switch its unmount to popupElement.remove() so it works
no matter which container it was appended to.
- Attachment dropzone overlay: teleport into the topmost open
dialog.modal-dialog instead of always <body>, mirroring Notification.vue,
so the drag-and-drop hint is visible while a task detail dialog is open.
Quick Add Magic with multiple labels (`*a *b *c`) fired all
`PUT /tasks/{id}/labels` requests concurrently via `Promise.all`. On
SQLite these overlap as read-then-write upgrade transactions, which the
busy_timeout can't resolve, so some requests fail with HTTP 500
("database is locked") and the labels are silently dropped while the
quick-add input gets stuck.
Expose a `concurrent_writes` flag on the shared `/info` response (true
for Postgres/MySQL, false for SQLite). The frontend config store reads
it and `addLabelsToTask` now branches: parallel `Promise.all` when the
backend supports concurrent writes, sequential awaits otherwise.
Fixes#2680
Notifications and emails showed the acting user's auto-generated
username instead of their display Name.
The doer attached to notification events was built straight from the
JWT via user.GetFromAuth, which only carries id + username (Name is
never set in GetUserFromClaims). Notifications render n.Doer.GetName(),
which falls back to the username when Name is empty, so every "assigned
you", "mentioned you", task-deleted, project-created and team-member
notification rendered the username.
Resolve the full user from the database at the event-producing dispatch
sites. doerFromAuth now re-fetches the user (with Name) and is reused by
all the notification doers; account-status errors are swallowed so flows
acting on behalf of disabled accounts (e.g. user deletion deleting that
user's tasks) keep working while still carrying the display name.
Fixes#2720
Centered default/hint-modal content used translate(-50%, -50%) with no
height cap, so a taller-than-viewport modal (e.g. project background
settings with the Unsplash grid) pushed its top edge above the viewport
where the container's overflow can't reach it — the upload button became
unreachable on short screens.
Cap the centered content to the viewport and scroll inside it, mirroring
the height limit the .top (quick actions) variant already has. The mobile
breakpoint resets both so the fullscreen layout keeps flowing in
.modal-container.
A transient database error while reloading a notification's user was
swallowed silently, leaving stale names with no trace. Log everything
except the expected "user was deleted" case.
Reflection over reflect.Kind was overkill: only top-level doer/assignee/
member fields are ever rendered, and the walk forced an exhaustive linter
exclusion. List the user fields per notification type instead, which drops
the reflect dependency and the .golangci.yml carve-out.
Notifications stored before the acting user was resolved with its full
profile (#2720) were serialized with only id+username, so they kept
rendering the auto-generated username instead of the display name.
Reload every embedded user from the database when reading a user's
notifications, healing already-stored rows at read time. The refresh is
not persisted; a per-page cache fetches each user once.
The duplicate task rows getTasksForProjects deduplicated came from the
LEFT JOIN multiplying when duplicate task_positions rows existed. The new
unique index on (task_id, project_view_id) removes the root cause at the
SQL layer (the migration also runs before serving), so the join can no
longer multiply. Revert getTasksForProjects and getRawTasksForProjects to
their pre-dedup shape.
filterNewTaskPositions ran one Exist query per position. createTask
calls it in loops (bulk import, project duplication), so this was
O(tasks * views) queries. Fetch all existing rows for the involved
tasks once and filter in memory instead.
A pair returned by the GroupBy was just reported as duplicated, so a row
must exist. Continuing on !has would let the delete loop drop every row
for that pair without re-inserting one, silently losing positions. Abort
the migration instead.
A task could end up with more than one task_positions row for the same
(task_id, project_view_id): rapid/concurrent creation raced the
check-then-insert paths, and the create path could insert a position that
a triggered RecalculateTaskPositions had already persisted for the new
task. The table had no unique constraint, so the duplicates were stored
silently (#2844).
In the table view this made the LEFT JOIN on task_positions emit the task
twice; getTasksForProjects enriched only the map entry, so the duplicate
slice row kept an empty identifier and rendered as "#N" instead of
"PREFIX-N" (#2725).
- Add a unique index on task_positions(task_id, project_view_id) via a
dedup migration (mirrors the task_buckets fix in 20250624092830) plus the
unique(task_view) struct tag so fresh installs get it too.
- Harden the create path: only queue a position insert when one does not
already exist for the task+view, and dedupe within the batch.
- Dedupe the task slice returned by getTasksForProjects by id, returning
the enriched entry, so duplicate position rows can never surface a task
twice or with a missing identifier.
Fixes#2844Fixes#2725
Adds GET /api/v2/ws as a raw echo route reusing the v1 upgrade handler.
WebSockets can't be modeled in OpenAPI and Huma has no WS support, so it
stays outside the Huma spec; it authenticates via its first message, so
unauthenticatedAPIPaths exempts it from the group's JWT middleware.
Also adds webtests covering all three /api/v2 non-CRUD endpoints: health
returns OK, ws is reachable without a JWT, and the atom feed is
basic-auth-gated. A spec test asserts /health and /notifications.atom
appear in the generated OpenAPI paths (atom with its application/atom+xml
response and BasicAuth security) while /ws is absent.
Adds GET /api/v2/notifications.atom as a Huma operation producing
application/atom+xml, so the feed shows in the v2 OpenAPI spec with an
opaque XML body schema. It mirrors /feeds/notifications.atom on the wire.
Feed readers can't carry a bearer header, so the op declares an HTTP
Basic security scheme (BasicAuth) and authenticates inside the handler:
it parses the Authorization: Basic header and validates the API token
via the shared feeds.AuthenticateFeedToken, returning a 401 with a Basic
challenge on failure, then streams feeds.BuildNotificationsAtomFeed. The
path is in unauthenticatedAPIPaths so the JWT middleware lets it through.
Splits the transport-agnostic cores out of the v1 echo handlers so the
v2 Huma endpoints can share them:
- AuthenticateFeedToken(s, username, password) holds the token
validation (prefix/length guard, owner match, feeds scope, bot
rejection); BasicAuth now creates the session and delegates to it.
- BuildNotificationsAtomFeed(s, u) renders the Atom XML;
NotificationsAtomFeed reads the context user and delegates to it.
- AtomContentType is shared so both transports set the same header.
The v1 handlers keep identical observable behavior.
Adds GET /api/v2/health as a Huma operation so it appears in the v2
OpenAPI spec with a clean JSON schema ({"status": "OK"}). It runs the
same health.Check() probe as the v1 healthcheck and is public — it opts
out of the global bearer auth and is listed in unauthenticatedAPIPaths.
POST /api/v2/user/token/refresh reads the HttpOnly refresh cookie, rotates
the session, mints a new JWT, and sets the new cookie — reusing the shared
auth.RefreshSession core (no v1 change) and the #2912 cookie helpers /
authTokenBody response shape. The cookie is set via the unwrapped echo ctx,
not the OpenAPI spec.
translateDomainError now maps *echo.HTTPError (which RefreshSession returns
for missing/invalid/expired/replayed tokens) so those land as the right
status instead of a 500. Completes the v1→v2 REST migration.
Relative dates ("5 minutes ago", "in 2 hours") were computed once via
dayjs().fromNow() and never recomputed, so a view left open kept showing
the value from the moment it was rendered.
Compute the relative string against the shared, ticking `now` from
useGlobalNow() instead. This makes every reactive caller — <TimeDisplay>,
direct formatDateSince() calls, and formatDisplayDate() when the user's
date display is set to relative — re-render on the existing 60s tick.
Absolute date formats don't read `now`, so they never needlessly
re-render.
useGlobalNow can now be initialised from a plain helper rather than only
from a component, so its route-update hook is guarded with
getCurrentInstance().
The login status check mapped a locked account to ErrAccountDisabled,
surfacing the disabled-account error code and message even though a
dedicated ErrAccountLocked exists (and the OIDC flow already uses it). Map
the locked status to ErrAccountLocked so credential login is consistent with
OIDC across both /api/v1 and /api/v2. Disabled accounts still return
ErrAccountDisabled.
This changes the v1 login error code for locked accounts on the wire (1020 ->
1026); the change is intentional and approved.
Restore the rollback-on-commit-failure that v1's Logout handler had before
this session-deletion logic was extracted, so a failed commit does not leave
the transaction open longer than the deferred Close.
Login asserts the token, the HttpOnly refresh cookie, the no-store header
and the credential/TOTP gates. Logout asserts the session is deleted and the
cookie cleared. OIDC coverage is the registrar gate (404 when disabled,
public route when enabled) — the full provider flow needs a live OIDC server,
as the existing openid package tests show.
Port the OIDC callback to Huma, reusing openid.AuthenticateCallback. The
route is only registered when OpenID is enabled; unknown providers still 404
per request. v1's bespoke {message, details} error body is replaced by
standard RFC 9457, folding the provider detail into the structured error.
Port the cookie-setting login and logout endpoints to Huma. Both reuse the
shared auth cores; the HttpOnly refresh cookie and Cache-Control: no-store
header are set via the unwrapped echo context (the cookie stays out of the
OpenAPI schema, matching v1). The token response inlines the JWT to avoid a
schema-name collision with user.Token.
login is public (LDAP-only deployments log in here too); logout inherits the
global JWT auth and no-ops for tokens that carry no session.
Pull the credential/TOTP check, session deletion, user-token issuance and
OIDC callback flow out of the v1 echo handlers and into reusable helpers so
both /api/v1 and the upcoming /api/v2 share one implementation:
- auth.IssueUserToken + auth.WriteUserAuthCookies split the token/cookie
machinery from the echo response; NewUserAuthTokenResponse now wraps them.
- auth.SessionIDFromContext reads the sid claim for logout.
- shared.AuthenticateUserCredentials, shared.DeleteSession hold the login
and logout cores.
- openid.AuthenticateCallback holds the OIDC exchange/getOrCreate/TOTP/team
sync, returning the user; HandleCallback issues the token as before.
v1 behaviour is unchanged on the wire.
GetUserDataExportStatus propagated the raw LoadFileMetaByID error when the
meta row was gone, so /user/export could 500. The download path already
maps that case to ErrUserDataExportDoesNotExist (404); make status
consistent by returning nil (no export), matching the documented contract.
DownloadUserDataExport obtained an open file reader from
GetUserDataExportFile but never closed it on either the s3 io.Copy or the
http.ServeContent branch, leaking a file descriptor on every download.
Defer the close right after the file is obtained so both branches and the
error paths cover it.
Move the Cache-Control: no-cache header into the shared WriteFileDownload
so every export and attachment download carries it, and add it to the
standalone v1 export download writer too. Downloads must never be cached.
If s.Commit() fails after loading the export file, the StreamResponse
callback that would close the reader never runs, leaking the open
object-storage/file handle. Close it explicitly on that error path.
Port POST /user/export/request, POST /user/export/download (zip stream) and
GET /user/export (status) to v2. Extract the export-file loader and status
builder into pkg/models (GetUserDataExportFile, GetUserDataExportStatus) with
a shared ErrUserDataExportDoesNotExist, and refactor v1 onto them. The v2
download streams via the shared WriteFileDownload writer; local users confirm
with their password, external-provider users are passed through.
Port GET /user/settings/totp/qrcode to v2 as an image/jpeg blob, modeled in
the OpenAPI spec. Extract the qr-to-jpeg encoding into user.GetTOTPQrCodeAsJpegForUser
so v1 and v2 share it; refactor v1 onto it. The handler reuses the existing
local-account guard, rejecting non-local users with 412.
Split the generic file-download writer (ServeContent for seekable readers,
manual 304 + io.Copy otherwise) out of WriteAttachmentDownload so other blob
endpoints can reuse it. The attachment writer keeps its preview branch and
cache override and delegates the rest.
On a saved-filter (or view-filter) kanban view, checkBucketLimit counted
the total number of tasks matching the filter instead of the number of
tasks actually in the target bucket. Adding the first task to an empty
limited bucket was therefore wrongly rejected with code 10004
"exceeded the limit", even though the bucket was at 0/limit. The same
setup on a regular project bucket worked because that branch counts
task_buckets rows scoped to the bucket.
Scope the count to the bucket by adding `bucket_id = <id>` to the
TaskCollection filter. ReadAll combines this with the saved-filter /
view filter, so the count reflects exactly the tasks that are in this
bucket and match the filter. This keeps the #355 behaviour (stale
task_buckets rows whose tasks no longer match the filter are excluded)
while fixing the unscoped over-count.
Fixes#2672
Focusing the task bar SVG `<g role="slider">` inside the
`overflow-x:auto` `.gantt-container` triggered Firefox's focus-induced
scroll-into-view, which jumped the scroll container back toward
`scrollLeft=0` (today). Pass `{ preventScroll: true }` to `focus()` so
selecting a bar keeps the current scroll position. Chromium scrolls
minimally on focus so it never manifested there.
Fixes#2728
MariaDB strict mode rejects the RFC3339 T/Z form for DATETIME columns. The space-separated form is accepted by MariaDB, Postgres and SQLite alike; the test only asserts on title and row counts, never the datetime.
MySQL/MariaDB/Postgres cannot bind a table name as a ? placeholder, so the non-SQLite branch failed with a syntax error. Interpolate the already-validated identifier with x.Quote (per-dialect quoting) instead. validateTableName restricts to registered table names, so this is injection-safe — the same trust model the SQLite branch already relies on. Latent bug surfaced by the new cross-engine testing webtest, which is the first to exercise this path on MySQL/MariaDB.
Port the testing fixture endpoints to /api/v2: PUT /test/{table} resets a
table to a posted fixture set and DELETE /test/all truncates everything.
Both authenticate with the configured testing token via a custom
Authorization header (not JWT/API-token) and only mount when that token is
set. Reuses the shared reset/truncate logic extracted from v1.
Pull the HTTP-agnostic table reset and truncate-all logic out of the v1
testing handlers into pkg/routes/api/shared so /api/v2 can reuse it. v1's
wire behavior is unchanged; it now delegates to the shared functions.
- Download: upload-then-download (real bytes), content-type, If-Modified-Since
304, read-only access allowed, no-access 403, unauthenticated 401, no
background 404, and the config-disabled route being absent.
- Unsplash proxies: routes absent when the provider is disabled, and 401 when
unauthenticated. The live Unsplash fetch is not exercised, matching v1.
Port the remaining read-only background blob endpoints to /api/v2:
- GET /projects/{project}/background streams the stored background (project
CanRead, in-handler), modeled as an image/jpeg binary response. Honors
If-Modified-Since (304) and serves through the shared WriteProjectBackground.
- GET /backgrounds/unsplash/images/{image} and .../thumb proxy the upstream
Unsplash image through the SSRF-safe client, gated on the unsplash provider
like the sibling unsplash routes, modeled as image/jpeg binary responses.
All three reuse the v1 business logic extracted in the previous commit.
Split the HTTP plumbing from the business logic in the v1 project-background
download and Unsplash image proxy handlers so /api/v2 can reuse it without
duplicating it:
- LoadProjectBackgroundForDownload (background/handler) loads the bg file +
modtime and fires the Unsplash pingback; GetProjectBackground now calls it.
- WriteProjectBackground (web/files) writes v1's exact background wire shape
(image/jpg, no-cache, stat-modtime Last-Modified, If-Modified-Since 304).
- FetchUnsplashImageByID / FetchUnsplashThumbByID (background/unsplash) return
the open upstream body for the caller to stream; the v1 proxy handlers now
call them. A typed ErrUnsplashImageDoesNotExist maps to 404 on both APIs.
- ErrProjectHasNoBackground (models) gives the no-background case a domain
error; v1 keeps its verbatim 404 message.
v1 responses are unchanged on the wire.
Desktop only has the v4 copy, so a plain override pins it to >=4.2.0
(resolves alert #245). The frontend also pulls js-yaml v3 via
gray-matter (histoire story tooling), which has no v4-compatible
release, so a scoped 'js-yaml@4' override bumps only the v4 copies
(eslint/cosmiconfig) and leaves gray-matter on 3.14.2. Alert #256
stays open for that dev-only, trusted-input path.
Resolves the form-data <4.0.6 advisory (predictable multipart
boundary). Transitive in both workspaces; pinned via pnpm overrides.
Dependabot alerts #247 (desktop) and #258 (frontend).
dompurify 3.4.0 was affected by several stacked advisories (mXSS /
sanitizer bypasses). 3.4.9 is past all vulnerable ranges. Resolves
Dependabot alerts #248-#254 (package.json) and #259-#265 (lockfile).
The frontend pins esbuild 0.28.1 directly, but vite/histoire and
@intlify/bundle-utils pulled in transitive copies (0.27.7 and 0.25.12)
still affected by GHSA-gv7w-rqvm-qjhr (RCE via missing binary integrity
verification) and GHSA-g7r4-m6w7-qqqr (dev-server file read on Windows).
A pnpm override forces all copies to the patched 0.28.1. Dependabot
alerts #239 and #241.
Resolves GHSA-7c78-jf6q-g5cm (type-confusion bypass of _assertPath
allowing path traversal). tmp was pinned to >=0.2.6 via pnpm overrides
in both the frontend and desktop workspaces, which resolved to the
vulnerable 0.2.6. Dependabot alerts #243 (desktop) and #244 (frontend).
Webtests for the file migrators (status, migrate, auth, missing-file) and the
CSV importer (status, detect, preview, migrate happy path, missing/malformed
config, empty file, auth). Each rejected upload is asserted to map to a 4xx
domain error rather than a 500.
Port the CSV importer's status/detect/preview/migrate endpoints to the Huma
API. detect/preview/migrate take a multipart upload; preview and migrate also
carry the import config as a JSON form value (modeled as a typed multipart
form field), unmarshaled in one shared place and reused via csv.RunMigration.
Port the file-based migrators' status + migrate endpoints to the Huma API.
A single registerFileMigrator helper wires all three (mirroring the OAuth
migrator registrar); the migrate endpoint takes a multipart upload under the
"import" field and reuses handler.RunFileMigration. POST migrate returns 200
since it runs an import rather than creating a REST resource.
Pull the StartMigration -> Migrate -> FinishMigration orchestration out of
the v1 echo handlers into handler.RunFileMigration and csv.RunMigration so
the v2 API can reuse the exact same business logic. v1 is refactored onto
them and stays byte-identical on the wire.
Also tag the CSV detect/preview/config DTOs with doc:/enum: so they carry
descriptions in the v2 OpenAPI schema (ignored by v1 swaggo/xorm).
Port PUT /projects/{project}/backgrounds/upload to the Huma-backed v2 API. The
multipart handler reuses handler.ValidateAndSaveBackgroundUpload (shared with
v1), checks project write access explicitly, and is gated on the upload provider
config flag. Adds webtests covering the happy path, auth/permission failures,
non-image rejection, the disabled-provider case and the multipart spec shape.
Extract the MIME validation, file storage and project reload from the v1
UploadBackground handler into ValidateAndSaveBackgroundUpload so the upcoming
v2 handler can reuse it instead of duplicating the logic. The v1 handler keeps
its exact wire behaviour; the inline "not an image" check now returns a typed
ErrFileIsNoImage that the handler maps to the same message.
Adding a context parameter to the shared package put its call chains in
contextcheck's scope; the flagged background context in the provider
setup is deliberate since provider lifetime exceeds any request.
The new v2 OAuth token endpoint mints a fresh session without going
through NewUserAuthTokenResponse, so those logins were missing from the
audit trail. The refresh grant stays unaudited like the v1 refresh.
ProjectUser.Create and friends are called with a nil auth in tests;
the old interface-typed Doer just serialized as null, so a nil doer
keeps that behavior (and maps to the system actor in the audit entry).
GetUserOrLinkShareUser re-fetches the account and fails its status
check, which broke deleting a disabled user's projects (the deletion
runs with the disabled account as doer). Convert the authenticated
principal directly instead — it also matches what the events serialized
before the doer became concrete, and drops a query per event.
ProjectUpdated/Deleted, ProjectSharedWith* and TeamCreated/Deleted
carried an interface-typed Doer that could not be unmarshaled, forcing
the audit registrations to decode anonymous mirror structs. Hydrate the
doer via GetUserOrLinkShareUser at the dispatch sites like the task
events already do, register the events directly and drop the untyped
audit registration path.
Webhook payloads for these events now serialize link share doers as
their pseudo-user (negative id) instead of the raw link share object,
consistent with task events.
Thread the request context through CheckUserCredentials so the
LoginFailedEvent carries IP, user agent and request id — without it,
failed logins were the one auth event useless for brute-force tracing.
All four callers have the request at hand.
Link share JWTs carry no sid claim so they returned before the event
fired, but the id claim was read without checking the token type. Make
the guard explicit so a link share id can never appear as a user id.
If both the rename and the reopen fail, logFile stayed nil while
initialized was still true, panicking on the next write. Propagate the
reopen error and retry the open on the next write so it self-heals.
Echo's RequestID middleware reuses the X-Request-Id header from a proxy
or generates one, so logging and audit all see the same ID. RequestMeta
previously read the request header before any later middleware could
have set one, leaving the audit request_id mostly empty.
The register handler, local/LDAP login and the OIDC callback all queue
the user.created event via DispatchOnCommit but never called
DispatchPending, so the event was silently dropped and its queue entry
leaked. Flush after commit and discard on rollback.
Every DispatchPending caller either has the request context in scope or
is genuinely request-less, so passing it as a parameter replaces the
stored-context mechanism on the pending queue and satisfies
contextcheck. Also fixes lint findings in the audit package.
One config-gated block in RegisterListeners maps every opted-in event
to its audit entry. Events with interface-typed doers are decoded via
a small doer ref that distinguishes link shares by their hash field.
LoginSucceededEvent fires from NewUserAuthTokenResponse (the chokepoint
where local, LDAP and OIDC logins converge), LoginFailedEvent from
handleFailedPassword on every failed password check, LogoutEvent from
the logout handler, and APIToken issued/revoked/used events from the
token model and auth middleware. The token events carry IDs only since
the freshly created token struct holds the raw token string and the
poison queue logs message payloads.
None of these events have a listener yet — the audit registration adds
them. Dispatching to a topic without subscribers is a no-op.
Entry schema with constructor-enforced actor/target types, a generic
RegisterEventForAudit helper that maps opted-in events to entries on
the existing watermill bus (license-gated per event since licenses are
runtime-mutable), and a JSONL writer with size-based rotation,
age-based cleanup of rotated files and batched fsync.
Adds a RequestMeta context bridge so events dispatched during an HTTP
request can be attributed to it: a middleware stashes IP/UA/request-id
on the request context, the generic Do* handlers associate that context
with the transaction key, and DispatchPending/DispatchWithContext copy
the metadata onto the watermill message at publish time. Existing
dispatch call sites are unchanged.
Per review: when registration is disabled, skip registering the
/register route entirely instead of registering it and returning 404 on
every request. A request to a disabled instance still 404s (unknown
route). ServiceEnableRegistration is static config, so the gate belongs
in the registrar.
Add webtests mirroring the v1 coverage for the v2 auth surface:
register (incl. registration-disabled 404), password reset request +
reset, email confirm, link-share auth (password matrix), the OAuth token
flow in both JSON and form-urlencoded encodings, oauth/authorize, the
token-test/check endpoints (200, not 418), /routes and link-share token
renewal (incl. user-token rejection).
Also make the link-share auth body optional so a passwordless share
authenticates with no request body, matching v1.
Port the token introspection helpers and link-share token renewal to
/api/v2:
- GET/POST /token/test both return a plain 200 "ok"; v1's POST 418
teapot easter egg becomes an ordinary success.
- GET /routes lists the scoped-token routes for both API versions
(models.GetAPITokenRoutes already merges v1 + v2).
- POST /user/token renews a link-share JWT; user tokens are rejected
(they must use the refresh-token flow), mirroring v1.
The renew response inlines the token field rather than returning
auth.Token directly, since Huma names schemas by bare type and a
top-level auth.Token body would collide with user.Token.
Port oauth/token and oauth/authorize to /api/v2, delegating to the
shared oauth2server.ExchangeToken / Authorize cores.
The token endpoint accepts spec-compliant application/x-www-form-urlencoded
bodies (RFC 6749) in addition to JSON; a form-urlencoded format is
registered on the v2 API that binds into the same json-tagged request
struct. The response carries Cache-Control: no-store. The token endpoint
is public; authorize inherits the global JWT auth.
Port the unauthenticated local-account flows and link-share auth to
/api/v2, delegating to the shared business logic:
- POST /register (404 when registration is disabled)
- POST /user/password/token, POST /user/password/reset
- POST /user/confirm
- POST /shares/{share}/auth
Local-account routes register only when local auth is enabled and the
link-share route only when link sharing is enabled, mirroring v1. Each
operation opts out of global auth and its path is added to
unauthenticatedAPIPaths.
Pull the HTTP-independent core out of the v1 auth handlers so both
/api/v1 and the upcoming /api/v2 routes share one implementation:
- oauth2server: ExchangeToken and Authorize take plain inputs and return
typed responses; HandleToken/HandleAuthorize keep binding + headers.
- pkg/routes/api/shared: AuthenticateLinkShare, RegisterUser,
ResetPassword (+ session clear), RequestPasswordResetToken and
ConfirmEmail, plus the shared UserRegister and LinkShareToken types.
v1 handlers now delegate to these; their wire output is unchanged.
The quick actions menu (cmd+k) rendered without any background and grew
beyond the viewport:
- Its card visuals came from the global Bulma .card styles, which were
dropped when Card.vue got its own scoped copy — QuickActions is the
only place using a bare class="card" div, so it lost background,
border and shadow. Give it its own card styles.
- Its height limit came from Bulma's .modal-content max-height, lost
when the Bulma modal import was dropped in the native-dialog refactor.
The :deep(.modal-content) position override in QuickActions never
matched (.modal-content is an ancestor of the scoped selector, not a
descendant). Replace both with a proper `top` modal variant that
anchors the content 3rem below the top edge and caps its height,
resolving the FIXME asking for exactly that option.
- The dark scrim never showed: Chromium intermittently stops painting a
styled ::backdrop (after subtree re-renders, or while display is
transitioned) even though getComputedStyle reports the color. Move
the scrim onto the viewport-filling dialog element itself — same as
the old div-based .modal-mask — and drop the display/allow-discrete
transitions, which the JS-timed close fade never needed.
Replace huma_backgrounds_misc_test.go with huma_background_test.go,
huma_info_test.go, huma_webhook_event_test.go and huma_user_search_test.go so
each route area's tests live in their own file.
Extract the duplicated user-search business logic into two helpers both API
versions call, and refactor v1's handlers onto them:
- user.SearchUsers wraps ListUsers + email obfuscation (global search)
- models.SearchUsersForProject wraps the project read check + ListUsersFromProject
Each handler keeps its own forbidden mapping (v1 echo.ErrForbidden vs v2
huma) so v1 stays byte-identical on the wire.
Port to /api/v2:
- GET /users (global user search by username/name/email; emails are blanked)
- GET /projects/{project}/users/search (users with access to a project, for
share autocomplete; requires project read access)
Both are custom routes: the project search loads the project and enforces
CanRead explicitly.
Add GET /api/v2/webhooks/events, listing the events a webhook target can
subscribe to. Gated on webhooks.enabled via a registrar early-return, mirroring
v1.
Add GET /api/v2/info (public — no auth). Extract the /info response type and
its assembly out of the v1 handler into pkg/routes/api/shared.BuildInfo() so
both API versions return byte-identical info; refactor v1's handler onto it.
Add the v2 path to unauthenticatedAPIPaths.
Port to /api/v2:
- DELETE /projects/{project}/background (remove background, returns the updated project)
- GET /backgrounds/unsplash/search (q, page; gated on the unsplash provider)
- PUT /projects/{project}/backgrounds/unsplash (set, gated on the unsplash provider)
Custom routes load the project and enforce CanUpdate explicitly. Backgrounds
are gated on the static backgrounds config via a registrar early-return.
Tag background.Image fields with doc: for the v2 schema, and add a scoped
contextcheck exclusion since the unsplash provider's shared interface bottoms
out in context.Background().
The admin set-admin-flag, set-status and delete-user operations were
implemented twice — once in the v1 echo handlers, once in the v2 Huma handlers.
Extract the load/guard/mutate logic into models.SetUserAdminFlag,
models.SetUserStatusAsAdmin and models.DeleteUserAsAdmin so both APIs call the
same code; each handler keeps only its own request binding, validation and
response shape. v1 stays byte-identical on the wire.
The patch-admin, patch-status and delete-user handlers each repeated the same
session open/load/commit/rollback scaffold. Extract it into adminMutateUser,
which owns the transaction and takes a closure for each handler's distinct
guard-and-write step.
Port the admin action endpoints to the Huma-backed /api/v2:
- GET /admin/overview instance counts + license snapshot
- POST /admin/users create a user (201)
- PATCH /admin/users/{id}/admin promote/demote (*bool, nil = unchanged)
- PATCH /admin/users/{id}/status set status (*Status, nil = unchanged)
- DELETE /admin/users/{id} delete (mode=now|scheduled, 204)
- PATCH /admin/projects/{id}/owner reassign project owner
All sit behind the existing gateV2AdminRoutes path middleware (admin + license
gate, 404 on failure), so no per-handler permission checks are added. The
hand-registered PATCH routes carry genuine partial semantics, which AutoPatch
does not synthesise. The admin user response reuses the existing
pkg/routes/api/shared package.
Move the admin overview computation and struct into models.BuildOverview /
models.Overview, the admin create-user flow into models.CreateUserAsAdmin /
models.CreateUserBody, and the admin user response view into a new
pkg/routes/api/shared package (shared.AdminUser / shared.NewAdminUser) so both
the v1 and v2 admin routes call the same code. The v1 handlers are refactored
onto these helpers and stay byte-identical on the wire.
Add /api/v2 auth/status/migrate endpoints for the three OAuth-based
migrators. One generic helper registers all three ops per migrator
behind its static config gate, so there's no copy-pasted block per
migrator.
The migrate kick-off orchestration (already-running guard + event
dispatch) is extracted into migrationHandler.StartMigration so v1 and
v2 share it; v1's wire output is unchanged. The guard now surfaces as a
typed migration.ErrMigrationAlreadyRunning (412) so v2 can translate it
through the standard error bridge.
Ports v1's task-list surface to /api/v2 as four endpoints. v1 served a
single polymorphic endpoint; v2 makes it monomorphic:
GET /tasks flat []*Task, all projects
GET /projects/{project}/tasks flat []*Task
GET /projects/{project}/views/{view}/tasks flat []*Task (even kanban)
GET /projects/{project}/views/{view}/buckets/tasks []*Bucket with tasks
The three task endpoints force flat tasks via TaskCollection so a kanban
view path no longer returns buckets; the dedicated buckets endpoint keeps
the polymorphic kanban branch and is not paginated (bounded by the view's
bucket config). Search is exposed as q; multi-value sort_by/order_by/expand
use ,explode. Hitting the buckets endpoint with a non-kanban view is a 400
rather than a type-mismatch 500.
v1's TaskCollection.ReadAll is polymorphic: a kanban view returns
[]*Bucket, everything else []*Task. v2 splits the task list into a
flat-tasks endpoint and a separate buckets-with-tasks endpoint, so the
flat endpoint needs ReadAll to return tasks even for a kanban view.
SetForceFlatTasks toggles that; v1 leaves it unset and keeps its shape.
Port the current-user account and settings endpoints from /api/v1 to the
Huma-backed /api/v2, calling the shared orchestration extracted into
models/user/openid:
- GET /user current user + settings + computed
auth_provider/is_local_user/is_admin
- POST /user/password change password (200, creates nothing)
- PUT /user/settings/email update email (kicks off confirmation)
- PUT /user/settings/general update general settings
- GET /user/settings/avatar/provider get avatar provider
- PUT /user/settings/avatar/provider set avatar provider
- GET /user/timezones list available time zones
These are current-user-scoped custom handlers (no per-resource Can*): each
pulls the authed user from the request context and operates on it. The avatar
provider get/set live on /user/settings/avatar/provider because v2 already
maps /user/settings/avatar to the binary avatar upload (PUT).
Pull the business logic out of the v1 current-user account/settings handlers
into reusable functions so both v1 and the upcoming v2 handlers call one
implementation. No behavior change — the v1 handlers keep their HTTP-layer
quirks (input binding, validation, error mapping); only orchestration moves.
Homes are forced by the import graph:
- shared.GetAuthProviderName (new pkg/routes/api/shared, above openid+user so it
can combine both without a cycle; routes-only helper)
- user.ChangeUserEmail (CheckUserCredentials + UpdateEmail, both in user)
- models.ChangeUserPassword (needs models.DeleteAllUserSessions; user can't import models)
- models.UpdateUserGeneralSettings / UpdateUserAvatarProvider
(need avatar.FlushAllCaches; user can't import avatar)
The general settings get a single shared wire struct, models.UserGeneralSettings
(tagged for both swaggo/govalidator and Huma): it is the update request body and
the nested settings on GET /user for v1 (replacing v1's UserSettings) and v2.
ExtraSettingsLinks is readOnly — populated from the user on read, ignored on
write. A dedicated struct is required because user.User's settings fields are
json:"-" so they don't leak when it is embedded in other responses.
User-controlled fields rendered into notification emails (task title via the
conversational header, comment and description bodies) were sanitized with a
bluemonday UGCPolicy that permits remote <img> sources. An attacker with write
access to a shared project could therefore inject an external image that acts
as a tracking pixel in a subscriber's inbox, leaking email-open time and IP.
Restrict notification-email images to inline data URIs (used by avatars) by
adding a RewriteSrc hook that blanks any non-data image src. The policy was
duplicated in three places, so extract it into newNotificationSanitizer.
Refs GHSA-2vr2-r3qw-rjvq
Port the per-user webhook endpoints (/user/settings/webhooks) from /api/v1 to
the Huma-backed /api/v2: list, available events, create, update, delete. They
are the project-less sibling of the project webhooks (#2858) and share the
webhooks.enabled gate, checked inside the registrar.
Webhook.ReadAll is extended to serve the user-level list (scoped to the
authenticated user) so the v2 list handler can go through handler.DoReadAll like
the project list; the project branch is unchanged. Credentials are masked on
read via the model's existing maskCredentials, matching #2858.
Ports the current-user TOTP (2FA) endpoints from /api/v1 to the Huma-backed
/api/v2: get status, enroll, enable, and disable. Each is a custom,
current-user-scoped handler that resolves the authenticated user and refuses
non-local (OIDC/LDAP) accounts, preserving v1's local-account-only guard.
The image/jpeg QR-code endpoint is intentionally not ported here; it is a
binary-streaming route deferred to a later wave.
Replaces the flat numbered project list during 'veans init' with the interactive picker. --project <id> still bypasses it; non-TTY stdin fails cleanly asking for --project.
Interactive bubbletea picker that renders projects as an indented tree (siblings by position then title, orphans re-parented to root) and fuzzy-filters as you type, keeping matched rows' ancestors visible as dimmed context. Pure tree/flatten logic is split from the TUI and unit-tested.
Smart-fill set the From time to the configured default start (09:00) when there
was no recent entry to continue from. Before that time of day the default lands
in the future, after the To time of now, producing an inverted range the backend
rejects (end_time before start_time). The save then failed silently and the
entry never appeared.
This surfaced as a flaky time-tracking e2e suite: the smart-fill specs failed
only when CI happened to run before 09:00 UTC.
Bot owners inherit read/update/delete permission on labels created by
bots they own, mirroring the bot-owner branch already used by API tokens
(see api_tokens_permissions.go). Without this, a label a bot creates is
permanently locked to that bot and the human owner cannot maintain it.
https://claude.ai/code/session_016x6mUPJuuQEeXpHY814iLh
The OpenID callback view used a localStorage "authenticating" flag to avoid submitting the same authorization code twice when the route was remounted during an auth layout swap.
That layout swap is now guarded by AUTH_ROUTE_NAMES, so openid.auth stays in the unauthenticated shell until redirectIfSaved() navigates away. The persistent flag can instead get stranded when the page is refreshed, closed, or interrupted during the callback, making future OIDC callbacks silently return before exchanging the code.
Remove the flag so each valid callback URL is processed normally while keeping the existing state validation and TOTP retry handling.
The admin-toggle handler delegates to handler.DoUpdate — the same pipeline
v1's UpdateWeb wraps — instead of re-implementing the session/permission/commit
orchestration. TeamMember.Update now carries the persisted row back onto the
receiver so both v1 and v2 responses include id/created.
Ports the v1 DatabaseNotifications routes to the Huma /api/v2 API:
- GET /notifications lists the caller's own notifications (paginated)
- PUT /notifications/{notificationid} marks one (un-)read
- POST /notifications is a custom action marking all as read; the
link-share guard, session and commit live in the handler since there
is no CRUDable Do* for a bulk mark.
Adds fixture rows and a webtest matrix mirroring the v1 model behaviour
(own-only visibility, mark-(un)read, link-share refusal on every route).
Port the Subscription resource from /api/v1 to the Huma-backed /api/v2:
POST /subscriptions/{entity}/{entityID} subscribes, DELETE unsubscribes.
The {entity} discriminator is bound as a string path param with an
enum:"project,task" tag; the model's CanCreate/CanDelete derive the numeric
EntityType from it and reject unknown kinds. Permissions and the
already-subscribed/forbidden checks come from the shared model via DoCreate/
DoDelete, identical to v1's generic handler. Mark the model's server-controlled
fields readOnly and add doc tags for the v2 schema.
In the v2 OpenAPI context a bare /webhooks/events reads as /api/v2/webhooks/events,
which does not exist — the events listing endpoint lives only on /api/v1. Point the
doc string at the absolute v1 path so v2 clients are not misled.
Webhook.ReadAll already cleared the secret and basic-auth from responses,
but Create and Update did not, so the v2 handler patched the gap with a
maskWebhookCredentials helper. Centralize the masking in the model via a
maskCredentials helper called after every DB write (ReadAll, Create,
Update) and drop the v2 handler helper.
The credentials are client-provided, not server-generated: the DB row
keeps them and outgoing deliveries reload + HMAC-sign from the DB copy,
so clearing the returned in-memory struct is correct write-only handling.
Webhook is a shared model, so v1's create/update responses also stop
echoing the submitted secret/auth — intended, and approved by the
maintainer.
Port the v1 webhook webtest to /api/v2 and extend it to the full
permission gradient the model enforces: list needs read access while
create/update/delete need write (Project.CanWrite), exercised across an
owned project and read/write/admin shares plus a no-access project. Also
assert credential masking, events-only updates, the 412 validation path,
and that the routes 404 when webhooks.enabled is false.
Add fixture webhooks 2-5 in projects 9/10/11/2 to back the matrix; they
do not collide with the e2e tests, which scope to project 1.
Port the project-webhook routes under /projects/{project}/webhooks to
the Huma /api/v2: list, create, update (events only), delete. There is
no ReadOne — webhooks carry secrets — so no max_permission and no
AutoPatch PATCH; update is PUT only, mirroring v1.
The resource self-registers and is gated by the webhooks.enabled config
flag inside the registrar (RegisterAll runs after config loads). The
write-only secret and basic-auth credentials are cleared from
create/update responses, matching how ReadAll masks them.
Add doc tags to every exposed Webhook field, mark the server-controlled
ones (id, project_id, user_id, created_by, created, updated) readOnly,
and mark the secret and basic-auth credentials writeOnly. All three tags
are ignored by swaggo/XORM/govalidator, so v1 is unaffected.
translateDomainError only recognized web.HTTPErrorProcessor, so a
ValidationHTTPError from InvalidFieldError (e.g. an unknown webhook
event) leaked as a 500 instead of the 412 v1 returns. It carries the
status via GetHTTPCode() but cannot implement HTTPErrorProcessor because
the embedded web.HTTPError field shadows the method name. Add a
GetHTTPCode/GetCode branch so v2 surfaces the right status and preserves
the v1 numeric code on the body.
Port the v1 /tasks/{projecttask}/assignees routes to the Huma-backed
/api/v2. The resource self-registers (RegisterTaskAssigneeRoutes) and
reuses the model's Can* methods via the generic Do* handlers:
- POST /tasks/{projecttask}/assignees → assign a user (body: user_id)
- GET /tasks/{projecttask}/assignees → list assignees (as users)
- DELETE /tasks/{projecttask}/assignees/{user} → un-assign
The list element type is []*user.User (assignees are returned as the
assigned users), which differs from the create body (a TaskAssginee
carrying user_id); the list handler type-asserts to []*user.User.
create/delete require write access to the task's project, list requires
read — enforced at the model level.
The webtest re-proves the full v1 permission matrix on the v2 surface
(read-only shares forbidden, write/admin allowed for create and delete;
already-assigned, no-project-access, missing-user, and missing-task
error codes) so v1's routes can be removed later.
Add doc: tags so Huma can describe user_id and created in the /api/v2
OpenAPI spec (it can't read Go comments), mark the server-set created
field readOnly, and give it an explicit json:"created" tag so it
serializes in snake_case like the rest of the v2 surface.
LinkSharing.CanRead resolved the parent project from the share hash, but a
by-id read (GET /projects/{project}/shares/{share}) only carries the numeric
id, never the hash — so the project lookup returned ErrProjectShareDoesNotExist
and every read-one 404'd, even for the share's owner. This affected both v1 and
v2.
Resolve the project from ProjectID when it is set (the by-id read path), keeping
the hash lookup as a fallback for resolving a share purely by its public hash.
The permission semantic is unchanged — you can read a share if you can read its
parent project; only the project lookup changes. ReadOne still scopes by
id AND project_id, so a share id from another project the caller can access is
not leaked (404, no IDOR).
Flips the v2 webtest's pinned 404 cases to assert success and adds the
cross-project IDOR and non-member negatives.
Port the LinkSharing resource from /api/v1 to the Huma-backed /api/v2 under
/projects/{project}/shares. Self-registers via AddRouteRegistrar and is gated
on ServiceEnableLinkSharing, checked inside the registrar so a disabled
instance exposes no routes.
There is no update operation, mirroring v1: a share is created, read, listed
or deleted, never modified in place. Permissions stay at the model level via
the generic Do* handlers (project write to create read/write shares and to
delete; project admin to create an admin share and to list).
ReadOne is ported faithfully including a latent v1 quirk: CanRead resolves the
parent project from the share hash, which the by-id route never carries, so a
by-id read always 404s. The webtest pins this so a future fix is deliberate.
Add doc:/readOnly:/writeOnly: tags to the shared LinkSharing model so the
Huma-generated /api/v2 schema documents every exposed field. password is
write-only (set on create, never returned); hash, sharing_type, id,
created, updated and shared_by are server-controlled and marked read-only.
swaggo/XORM/govalidator ignore these tags, so v1 is unaffected.
Port the BotUser resource from /api/v1's /user/bots routes to the
Huma-backed /api/v2, preserving every v1 behavior:
- Full CRUD at /user/bots and /user/bots/{bot} with v2 verbs (POST
creates, PUT updates; PATCH is synthesised by AutoPatch).
- ReadAll returns only the caller's own bots; read/update/delete of an
unowned or missing bot is refused with 403, since ownership is resolved
by loading the user (no existence disclosure, no 404 branch).
- Create requires a real user account and rejects link shares, the
bot- username prefix is enforced, and bots are created without an
email or password — all delegated to the unchanged model layer.
- ReadOne surfaces max_permission via the shared value-embed pattern and
carries an ETag for conditional requests.
doc/readOnly tags are added to the exposed user.User fields the bot
response surfaces, and to BotUser.Status, so the v2 OpenAPI schema is
documented. The model and v1 routes are untouched.
The webtest ports the v1 model-level permission matrix to the v2 HTTP
surface and adds the v2-only ETag/304 and merge-patch coverage.
Port the APIToken resource from /api/v1 to the Huma-backed /api/v2 at
top-level /tokens. List/create/delete only — no ReadOne, no Update,
matching v1. The list operation accepts an owner_id query param to list
a caller-owned bot's tokens; create returns the cleartext token exactly
once. Permissions are enforced by the model via the shared Do* helpers.
The webtest ports the v1 model-level permission matrix onto the v2 HTTP
surface (owner isolation, exact list cardinality, bot-owner authz,
validation, forbidden delete) so v2 proves the contract independently.
Add doc:/readOnly: tags (and minLength on title) so the Huma-backed
/api/v2 surface documents and schema-validates APIToken. Tags are
inert for v1 (swaggo/XORM/govalidator ignore them).
Port the LabelTask resource (labels attached to a task) from the frozen
/api/v1 to the Huma-backed /api/v2 as nested routes under
/tasks/{projecttask}/labels:
- GET list the labels on a task (read access to the task)
- POST attach a label to a task (write access to the task + access to the label)
- DELETE detach a label from a task (write access to the task)
There is no read-one or update for a label-task relation, so no
max_permission. Adds doc tags and marks the server-set created timestamp
readOnly on the shared model. Permissions stay enforced at the model
layer via the existing Can* methods through handler.Do*.
Ports the Session resource from /api/v1 to the Huma-backed /api/v2 with
list and delete only — sessions are created by the login flow, not CRUD,
so there is no create/read-one/update (and no max_permission or AutoPatch).
The delete path param is a string UUID (path:"session"), not an int64 id,
mapping to the model's string ID. ReadAll is type-asserted to
[]*models.Session; permissions stay at the model level via DoReadAll/DoDelete.
The v2 webtest mirrors v1's session-CRUD matrix (list own vs others',
delete own, non-owner forbidden, nonexistent 404). The login/refresh
auth-flow cases stay on v1.
Every Session field is server-controlled (sessions are created by login,
not CRUD), so all exposed fields get readOnly:"true". The doc tags feed
Huma's reflected /api/v2 schema; they are inert for v1.
Assert the read-one body carries max_permission, and add
TestHumaTaskComment_ETagReflectsPermission proving two users with different
permission on a comment's parent task (project 9: owner user6 vs read-share
user1, comment 6 on task 18) receive different ETags.
Convert taskCommentsRead to the labelReadBody embed pattern: return a
taskCommentReadBody that embeds models.TaskComment and adds a read-only
max_permission field, folded into the ETag via conditionalReadResponse so
a permission change invalidates a cached read. The update handler takes the
same read-shaped body so AutoPatch's GET->PUT echo of max_permission validates.
Decode the ReadOne/Normal body and assert MaxPermission equals the real
permission (admin for the owner) instead of substring-matching, so a
regression to 0 or null is caught precisely.
Add TestHumaProject_NullMaxPermissionRoundTrips: create/update return
max_permission:null, and PUTting that response body back verbatim must
succeed (200, not 422). max_permission is readOnly so Huma ignores it on
write, and Permission.UnmarshalJSON treats JSON null as a no-op.
The project test port had added db.LoadFixtures() into the shared
webHandlerTestV2.serve(), reloading fixtures before every request. That
wiped runtime-created rows between requests within a test, breaking the
create-then-read-back contract every v2 resource relies on (e.g.
TestHumaTeam/Create/Public read its freshly-created team back and got 403).
Revert that shared-harness change and isolate the project/archived tests
the way the team and label tests do: each subtest builds its own handler
via handlerFor, so it runs against freshly loaded fixtures (setupTestEnv
reloads once per handler), while a create-then-read-back sequence reuses
one handler within the subtest.
Read-one now returns a projectReadBody embedding models.Project with
max_permission always populated from CanRead, matching the labels/views
value-embed pattern instead of gating it behind expand=permissions.
CanRead yields a real permission for every readable project (Favorites
pseudo-project and saved-filter-backed ones included), so the field is
always meaningful on a read.
Project remains the no-ETag exception: the response carries user-scoped
favorite/subscription state that changes without bumping Updated, so it
is served fresh.
Update routes its body through the read shape so AutoPatch's GET→PUT echo
of the read-only max_permission validates. Create/Update return null for
max_permission (not computed there) rather than a misleading 0 (=read).
Bring the v2 project webtest to 1:1 parity with v1's TestProject and
TestArchived so the v2 routes independently prove everything v1 proved:
- Full sharing matrix on ReadOne/Update/Delete across team, user,
parent-team and parent-user shares x read/write/admin, asserting
allow/deny and (for ReadOne) the granted max_permission level via
expand=permissions (v2's replacement for v1's x-max-permission header).
- Create permission matrix via parent_project_id (forbidden parent,
parent-team/user write+admin allowed, read-only denied), nonexistent
parent (404), and title-too-long (422) on both Create and Update.
- Create response assertions (owner echo, description, tasks not embedded).
- ReadAll search (q=) with exact cardinality and archived propagation to
child project 21.
- New TestHumaArchived ports the HTTP-observable archived behaviours:
no edit/unarchive under an archived parent, self-archived edit denied
but unarchive allowed, and archiving a project (412 / ErrCodeProjectIsArchived).
Make webHandlerTestV2.serve reload fixtures per request, mirroring v1's
per-request fixture reload, so mutating subtests don't leak state across
the shared Echo instance.
The project read response is enriched with user-scoped, derived state
(subscription, favorite, views, computed archived state) that can change
without bumping project.Updated. An ETag built only from Updated would
therefore hand out stale 304s and hide those changes from the client.
Serve project reads fresh on every call by returning the no-ETag
singleBody envelope and dropping the conditional.Params input. Labels
keep their ETag because their response has no such volatile derived
fields. Update the ReadOne/Normal webtest to assert no ETag is sent.
The project read handler left MaxPermission at its zero value when
expand=permissions was not requested, which serialised as 0
(PermissionRead) instead of being omitted. Force PermissionUnknown so
the field marshals as null, matching the list operation's behaviour and
avoiding a misleading read permission for projects the caller may own.
Assert the null shape in the ReadOne/Normal webtest.
Add a simple /{id} CRUD resource for projects on the Huma-backed /api/v2,
mirroring labels.go. Exposes the expand query param (value "permissions")
which surfaces the caller's max permission per project on both list and read.
The handler stays standard (DoReadAll/DoReadOne/DoCreate/DoUpdate/DoDelete);
the model's ReadOne keeps handling the Favorites pseudo-project and
saved-filter-backed projects.
Self-registers via init() -> AddRouteRegistrar; no routes.go change.
projectusers is intentionally out of scope.
Label writes/deletes are owner-only (CanUpdate/CanDelete), but hasAccessToLabel
derived max_permission from the accessible task's permission with a read fallback
for the creator branch — so owners showed as read-only and a task-admin reading
a label via that task showed as a label admin. Derive it from ownership instead:
owner -> admin, otherwise read. Corrects the value CanRead returns for both v1's
x-max-permission header and the new v2 max_permission body field.
Read/update use a per-resource struct that embeds the model by value and adds a
readOnly max_permission field (labelReadBody, projectViewReadBody); Go and Huma
promote the embedded fields, so the body stays flat with no custom marshaler and
nothing on the shared models. The handler passes the model's Updated and the
permission to conditionalReadResponse, which folds the permission into the ETag.
Adds a webtest asserting two callers with different permission on the same label
get different ETags, plus max_permission presence assertions.
conditionalReadResponse applies the If-Match/If-None-Match/If-Modified-Since
precondition (304/412) and returns the shared read envelope. The caller's
permission is folded into the ETag so a share/role change invalidates the cache
even when the model's modified time is unchanged.
Also adds doc: tags to the shared ListParams (q/page/per_page).
Adds a Code Style bullet: default to no comment; only document a non-obvious
why (gotcha, invariant, rejected alternative) in one tight line; cut comments
that restate the code, a name, or a signature.
Assert the specific domain error code (ErrCodeTaskDoesNotExist) on the
nonexistent-source-task case, matching v1's TestTaskDuplicate. v2 carries
the code as the numeric `code` field of the RFC 9457 problem+json body,
so the test now checks that field instead of only the 404 status.
Bring the v2 avatar webtest to 1:1 parity with the v1 avatar tests so
the v1 routes and tests can be removed without losing coverage:
- link-share auth path: a request authenticated as a link-share user
(not a regular JWT) returns 200 + non-empty image bytes, porting
v1's TestLinkShareAvatar.
- bot user: the botmarble provider path returns 200 + SVG bytes, a
distinct rendering v1 never exercised; asserts the marble mask id so
it cannot silently fall through to the default placeholder.
- non-numeric size: rejected with 422 (Huma's int64 query validation)
rather than v1's 400 ErrInvalidModel, both being client errors that
refuse the malformed input.
Cut narration a reader can infer from the code (envelope element type,
path-param binding, per-case test descriptions). Keep the non-obvious
rationale: IDOR scoping, RFC 9110 etag quoting, why the feature gate sits
in the registrar, and the author-only fixture crux.
The Forbidden non-author update/delete cases used user6, who also lacks access
to task 1, so they only proved access denial, not the author-only restriction.
Add cases driven by testuser1 against comment 4 on task 16 (project 7): user1
has write access via team 3 but did not author the comment (user6 did), so a
403 there genuinely exercises the authorship branch. Keep the user6 cases as
the no-access negatives, relabelled for clarity.
TaskComment.Update used tc.Author as the TaskCommentUpdatedEvent doer, but
that field is bound from the request body. A client could omit it (nil doer,
breaking the event) or spoof another user. Resolve the doer from the session
auth via GetUserOrLinkShareUser instead, mirroring Create and Delete. CanUpdate
already guarantees the authenticated user is the comment's author, so this is
both correct and consistent. Affects v1 and v2, which share the model.
Add TaskComment CRUD on /api/v2 under /tasks/{task}/comments, mirroring
the project_views nested-resource shape. The resource is feature-gated by
config.ServiceEnableTaskComments, checked inside the registrar so it runs
after config has loaded. Self-registers via init()+AddRouteRegistrar; no
routes.go change. ReadAll exposes the order_by (asc/desc) query param.
Adds doc:/readOnly: tags to the shared TaskComment model fields and a
TestHumaTaskComment webtest covering list/read/create/update/delete plus
negatives (non-author forbidden, comment under the wrong task -> 404).
Bring TestHumaAdminProjects to 1:1 parity with v1 TestAdmin_ListProjects
by asserting owner hydration ("username":"user1", never "owner":null)
and project field presence ("id":, "title":) on the response body, in
addition to the existing gate personas and ownership/archived visibility
cardinality checks.
Bring the merged v2 Label webtest (TestHumaLabel) to 1:1 parity with the
model-level matrix in pkg/models/label_test.go so the v2 HTTP surface
independently proves the full visibility/permission contract once v1's
routes and tests are removed.
Added scenarios:
- ReadAll asserts the EXACT visible set for user1 = {1,2,4,7,8}, with #3
(other owner, unattached), #5 (other owner, inaccessible task) and #6
(GHSA private fixture) explicitly absent — not just contains/not-contains.
- ReadOne: #3 forbidden (other owner, unattached); #6 forbidden (GHSA
private); #4 ALLOWED (other owner but visible via an accessible task);
#7 allowed (own, unattached); #8 allowed (own, only on inaccessible task).
- Update/Delete: #4 forbidden (GHSA-hj5c-mhh2-g7jq read-vs-write: readable
but not writable by the non-owner); #3 forbidden; #6 forbidden.
- Create asserts hex-color normalization (#aabbcc -> aabbcc).
Keeps the existing ETag/304 and merge-patch subtests.
include_public is a list-time query flag, not a team field. With json:"include_public" it leaked into the v2 Team request/response body schema (POST/PUT). Mark it json:"-" so it only travels as a query parameter: v1 binds it via the query tag, and the v2 list handler takes it as a dedicated query field and sets it on the model internally.
Adds Team CRUD on /api/v2 mirroring the labels reference resource:
list, read, create, update, delete under /teams[/{id}].
- The list op exposes an include_public query param bound onto the
model so Team.ReadAll can surface public teams (gated by the instance
public-teams setting).
- Read ops emit an ETag and honor If-None-Match (304).
- Model fields gain doc: tags; server-controlled fields are marked
readOnly:true.
- Self-registers via init()/AddRouteRegistrar; no routes.go change.
- New webtest TestHumaTeam (named to avoid clashing with the v1 model
TestTeam) covers list/read/create/update/delete plus negatives
(non-member 403, nonexistent 403/404) and ETag/304.
A shared/embedded query-param helper struct silently fails to bind under Huma
when combined with other query params (found implementing Project's expand);
each query param must be a direct field on the operation's input struct.
- Move each resource file's init() below its RegisterXRoutes func so the func doc
comment stays attached (it was documenting init()).
- Note AddRouteRegistrar is init-only and not concurrency-safe.
- Reword RegisterAll: registrar order is unspecified and irrelevant.
Update the api-v2-routes skill: resources self-register via
func init() { AddRouteRegistrar(RegisterFooRoutes) } instead of editing
registerAPIRoutesV2. Note distinct-registrar-name requirement (RegisterAvatarRoutes
collision), config-gating inside the registrar, and AutoPatch being automatic via
RegisterAll. Also flag that mage test:filter injects -short (which skips
pkg/webtests entirely), so run single webtests with go test -run.
Previously every new v2 resource appended an explicit RegisterXRoutes call
(and the EnableAutoPatch line had to stay last) in registerAPIRoutesV2 in
routes.go, causing recurring merge conflicts across in-flight PRs.
Resources now self-register: each resource file calls AddRouteRegistrar from
an init(), and registerAPIRoutesV2 just calls apiv2.RegisterAll, which runs
every registrar and then EnableAutoPatch. New resources touch zero shared
lines.
Avoids a duplicate RegisterAvatarRoutes declaration in package apiv2 now that
the avatar GET route (#2818) is on main; both routes are registered distinctly.
MaxBodyBytes was set to exactly the configured max file size, but a
multipart request carries extra bytes (boundary, part headers) on top of
the file, so a file at the limit could be rejected by Huma before the
handler runs. Mirror the +2 MB overhead that Echo's global BodyLimit
middleware already allows so a max-sized avatar isn't rejected.
Browsers set a real image Content-Type (image/png, image/jpeg, ...) on
the multipart avatar part, while programmatic clients often send
application/octet-stream. The part contentType tag is an allow-list for
Huma's MimeTypeValidator, which runs before the handler; broaden it so
both cases are accepted instead of being rejected with a 422.
The byte-level mimetype.DetectReader check in the handler remains the
real security gate and is unchanged.
Extend the webtest with a case that sends a part declared as image/png
and asserts it reaches the handler successfully.
Add PUT /api/v2/user/settings/avatar, the first multipart/form-data file
upload on the Huma-backed v2 API. Reuses v1's byte-level mime validation
(mimetype.DetectReader) and storage (upload.StoreAvatarFile), modeling the
request as a huma.MultipartFormFiles input so it renders as multipart/form-data
in the OpenAPI spec instead of being read off the raw echo context.
Flips the user's avatar provider to "upload" on success. Authenticated (JWT).
Extract the duplicated user-lookup, provider-selection and size-clamping
logic from the v1 GetAvatar and v2 avatarGet handlers into a single
avatar.GetAvatarForUsername helper. Both handlers now call it and keep
only their transport-specific code (v1: echo size parse + c.Blob, v2:
huma input/response). Pure refactor, behavior is unchanged.
Add GET /api/v2/avatar/{username}, the v2 reference for a binary response
modeled in the OpenAPI spec. Reuses the v1 avatar provider logic (provider
lookup, size clamp to config.ServiceMaxAvatarSize, runtime content-type) and
returns raw image bytes via Huma's []byte body + dynamic Content-Type header
idiom, advertised in the spec as application/octet-stream.
The endpoint is authenticated under the global security like every other v2
route (an anonymous request gets a 401); it is not public.
Add the admin + license gate for /api/v2 and ship the first gated
resource, GET /api/v2/admin/projects (AdminProjectList).
The gate reuses the existing v1 middleware functions unchanged —
RequireFeature(license.FeatureAdminPanel) and RequireInstanceAdmin(),
both of which serve 404 on failure. Rather than splitting the single
v2 Huma API into a separate gated sub-group (which would split the
OpenAPI spec and drop admin operations from /api/v2/openapi.json), the
gate is applied as a path-scoped Echo middleware on the shared /api/v2
group, firing only for /api/v2/admin/* and after the token middleware.
This preserves v1's 404-not-403 semantics and keeps admin routes in the
unified v2 spec and Scalar docs.
AdminProjectList lists every project on the instance (archived
included), behind the gate. Adds doc:/readOnly: tags to the shared
Project model so it documents correctly as a v2 schema.
Tests in pkg/webtests/huma_admin_test.go (TestHumaAdminProjects) cover
all three personas: non-admin -> 404, admin without feature -> 404,
admin with feature -> 200 list, plus unauthenticated -> 401.
Add ProjectView CRUD on /api/v2 under the nested path
/projects/{project}/views[/{view}], establishing the two-path-param
binding pattern for sub-resources. Mirrors the labels.go handler shape
and reuses handler.Do* so permission checks stay at the model layer.
Both {project} and {view} are bound on every operation; {project} is
threaded onto ProjectView.ProjectID (ReadOne resolves via
GetProjectViewByIDAndProject, which needs the parent id). List wraps the
[]*models.ProjectView slice in the shared Paginated envelope, read sends
an ETag for If-None-Match/304, and AutoPatch synthesises PATCH.
Also:
- Tag exposed ProjectView / ProjectViewBucketConfiguration / nested
TaskCollection fields with doc: descriptions; mark server-controlled
fields (id, project_id, created, updated) readOnly. Safe for v1.
- Give ProjectViewKind and BucketConfigurationModeKind a huma.SchemaProvider
so the string-serialised enums reflect as string schemas instead of
Huma's default integer schema (which rejected the string form with 422).
Routes registered in registerAPIRoutesV2 before EnableAutoPatch.
Collapsing unparseable taskIds to 0 meant sortParentsBeforeChildren,
which tracked placement by TaskID, treated every zero-id task after the
first as already placed and silently dropped it. Track placement by task
identity instead so duplicate or zero ids never conflate distinct tasks.
TickTick exports could contain non-numeric values in columns Vikunja
parses as integers (Priority, taskId, parentId). gocsv's strconv.ParseInt
then failed, aborting the entire import and surfacing as an internal
server error reported to Sentry (e.g. parsing "p1": invalid syntax).
Numeric ID columns now fall back to 0 for unparseable values instead of
failing the import. The Priority column, which was previously parsed but
never carried over to the imported task, is now mapped onto the task and
accepts both the plain numeric form (0, 1, 3, 5) and the "pN" form
(p1, p2, p3).
Closes#2822
The `service.allowiconchanges` config option was ignored. On the web ui the
value injected into index.html by the api was immediately overwritten by a
hardcoded `window.ALLOW_ICON_CHANGES = true` in a later inline script, so the
configured value never took effect. The desktop app never received the
injected value at all, since it serves the bundled frontend from its own local
server and only talks to the api for data.
Expose the option via the /info endpoint and read it from the config store,
which is the only channel that reaches both the web ui and the desktop app.
The brittle window injection and its hardcoded default are removed in favor of
this single source of truth.
https://claude.ai/code/session_01HAXTJNsDcfsB4hwDNKTECb
v2's OpenAPI spec is generated from struct tags and Operation fields at
runtime; unlike swaggo (v1) it can't read Go doc comments, so v2 shipped
without the field/operation descriptions v1 has. Add doc: tags to the
Label model (kept in sync with the existing comments swaggo reads for
v1) and Summary/Description to each label operation. Makes labels a
complete reference for the pattern.
The normalizer's docstring and stripBracketSuffix's pair-by-pair walk
promise left-to-right order preservation (load-bearing for sort_by /
order_by), but the only coverage used order-insensitive assert.Contains
after 02e10b287 dropped the dedicated test. Add exact-match assertions
that a mix of plain and bracketed forms re-emits values in send order.
Huma's handler-error path wraps raw errors as NewErrorWithContext(ctx,
500, "unexpected error occurred", err), and since the humaecho5 adapter
writes Huma's response directly it bypasses Vikunja's
CreateHTTPErrorHandler — which returns a generic 500 with no detail for
non-domain errors. The huma.NewError override then copied err.Error()
(raw DB/driver messages, SQL, table/column names) into the problem+json
errors[], a regression vs v1.
Override huma.NewErrorWithContext to drop errs for status >= 500, log
the real cause server-side, and return a generic body. 4xx detail
(validation errors, domain messages) is unaffected.
PermissionsAreValid only consulted apiTokenRoutes, so a v2-only resource
(no v1 counterpart) could never be granted as a token scope even though
CanDoAPIRoute already authorises against both tables. Validate against
the union so the v1+v2 authorization and validation paths agree.
translateDomainError discarded web.HTTPError.Code, so v2 error bodies
always read code 0 — losing the v1 contract the error docs key off.
Override huma.NewError with a VikunjaErrorModel that adds a code field,
so both the generated OpenAPI schema and runtime responses carry it.
Domain errors with a numeric code now surface it (e.g. 8002 for a
missing label, matching v1); errors without one omit it.
authFromCtx surfaced the underlying GetAuthFromContext error message
(e.g. the internal 'no echo.Context' adapter detail) straight to the
client. Log the real error and return a generic 401 instead.
The authenticated v1 group installs setupRateLimit and
setupMetricsMiddleware; the v2 group only had cache-control and token
middleware, so authenticated v2 endpoints bypassed the configured API
rate limiter and route metrics. Mirror the v1 stack.
Adds the api-v2-routes agent skill (CRUD + non-CRUD shapes, descriptions
required) and marks /api/v1 frozen in AGENTS.md/CLAUDE.md so new routes go
to the Huma-backed /api/v2.
Huma's SchemaLinkTransformer (enabled by default) emits a `$schema`
field on every JSON response and an example URL in the spec. Both were
broken in our setup: the example URL used Huma's "https://example.com"
placeholder because no Servers were declared, and the runtime URL
pointed at /schemas/Label.json instead of /api/v2/schemas/Label.json
because Huma can't see the Echo group prefix.
Two changes:
- Set OpenAPI Servers to a list with the relative GroupPrefix first and,
if service.publicurl is configured, the absolute deployment URL
second. Servers[0] feeds Huma's getAPIPrefix / addSchemaField /
Transform fallback; Servers[1] is informational metadata for SDK
generators and docs UIs. Keeping the relative URL at index 0 dodges a
Huma quirk that double-prefixes the runtime $schema URL when the
index-0 server URL carries a path component.
- Add /api/v2/schemas/:schema to unauthenticatedAPIPaths so editors and
SDK tooling can fetch schemas without a token, mirroring how the spec
itself is reachable.
Seven integration tests covering the Label pilot:
- Create_Read_Update_Delete — full round-trip through POST/GET/PUT/
DELETE, asserts body + status at each step.
- List_ReturnsItems — GET /labels, asserts items[] is non-empty and
contains a known fixture; this is the regression catcher for the
generic-any silent-empty trap the spike hit.
- ForbiddenErrorShape — user1 reading user13's private label returns
403 problem+json with the RFC 9457 type/title/status/detail shape.
- ValidationErrorShape — POST with empty title fails Huma's
minLength:1 check with 422 problem+json + structured per-field
errors locating `title`.
- ETagReturns304 — first GET captures ETag, second GET with
If-None-Match returns 304.
- PATCHMergePatch — AutoPatch-synthesised PATCH with partial
application/merge-patch+json body updates one field and leaves
the others untouched; a follow-up GET confirms preservation.
- OpenAPISpecDescribesAllFive — the unauthenticated
/api/v2/openapi.json surfaces GET+POST on /labels and GET+PUT+
DELETE on /labels/{id}.
Wires five hand-written huma.Register calls for Label CRUD onto the
existing /api/v2 group: list, read, create, update, delete. Uses
concrete type cast on ReadAll to avoid the generic-any silent-empty
trap. The read operation exposes an ETag via a header-tagged output
struct field and honours conditional.Params so clients can get 304
Not Modified on subsequent reads.
Also closes a prior-phase gap: SetupTokenMiddleware was intended to
run on the /api/v2 group (per task B4 of the plan) but was never
wired. Attach it now and teach the skipper to consult
unauthenticatedAPIPaths so spec + docs remain public.
The /api/v1 group sets Cache-Control: no-store to prevent browsers
from heuristically caching JSON responses. /api/v2 was missing the
same header, which could lead to stale reads. Extracted the inline
middleware into a shared noStoreCacheControl helper and applied it
to both groups.
Huma's AutoPatch synthesises a PATCH counterpart for every PUT, and both
verbs collapse to the same "update" permission. PATCH is still skipped
during collection (it would clobber PUT under the shared key), but the
matcher now accepts it as an alias for the stored PUT route on the same
path, so token holders aren't forced to use PUT exclusively.
Sub-phase G validation caught that a token scoped to e.g.
`labels.read_one` was rejected on /api/v2/labels because the route
collector only stripped /api/v1/ from paths and did not know about
v2's REST-style verbs (POST create, PUT/PATCH update, inverted
from v1 where PUT creates and POST updates).
Introduce a shadow apiTokenRoutesV2 map keyed under the same
(group, permission) names as the v1 entries. Route collection now
routes v2 paths into this shadow map and CanDoAPIRoute consults
both tables, so the same permission bit authorizes the v1 and v2
endpoints for the same resource without changing the data shape
served at /api/v1/routes (which the frontend token UI depends on).
Also teach getRouteDetail about PATCH so Huma's AutoPatch-synthesized
PATCH routes collapse to the `update` permission instead of being
dropped.
Promotes huma/v2 to a direct dep (now imported by pkg/routes/api/v2 and
pkg/modules/humaecho5) and bumps clipperhouse/displaywidth to v0.11.0,
which is required for compatibility with uax29/v2 v2.7.0 that huma pulls
in. Other version bumps are go-mod-tidy consequences of MVS.
The scratch image shipped /tmp owned by 1000:1000 and writable only by
UID 1000, so containers run under a different user (e.g. Unraid's
99:100, OpenShift random UIDs, or any `user:` override) could not create
the temp file used for data exports, failing with:
error creating temp file: open /tmp/vikunja-export-*.zip: permission denied
The builder-stage `chmod 1777 /tmp` did not survive into the final image
(see #2316, which had to add --chown to make it writable for UID 1000),
so the world-writable intent was lost. Force the mode at copy time with
BuildKit's --chmod=1777, restoring a normal sticky, world-writable /tmp
that works for every UID.
Closesgo-vikunja/vikunja#2755
A GetWithValue deserialization error in RememberFor was returned as fatal.
On a Redis upgrade the metrics counters live under the same keys as before
but were stored as plain int64, so the first decode into the new envelope
would fail and the metric would break permanently. Treat such errors as a
miss and recompute/overwrite so the cache self-heals.
Instead of priming a counter at startup and keeping it in sync via events,
each entity count is now read directly from the database and cached for
30s (countCacheTTL). The cache is the correctness guarantee: counts are at
most one TTL stale and self-healing, so they can never permanently drift.
This fixes vikunja_user_count never updating after registration (#2650):
the count no longer depends on every mutation path dispatching an event.
The tray icon was loaded from desktop/build/icon.png, but build/ is
electron-builder's default buildResources directory, whose contents are
not packaged into the app. The icon therefore existed when running from
source but was missing in every released build, leaving an empty tray
icon.
Load the icon from the packaged app root instead and add icon.png there,
rendered from the circular logo.svg so it has transparent corners rather
than the square full-bleed source artwork.
Fixes#2668
The postinstall scripts generated the jwt secret with:
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1
This relies on SIGPIPE to terminate the infinite `cat /dev/urandom`
once `head` has read its single line. Inside a dpkg/apt maintainer-script
context the SIGPIPE disposition is not reliably delivered, so
`cat /dev/urandom` spins forever, the postinstall never returns, and the
whole `dpkg -i` / upgrade hangs.
Read a bounded 512 bytes with `head -c` instead so nothing depends on
SIGPIPE to terminate. 512 random bytes yield ~124 alphanumerics on
average, so the trailing `head -c 32` reliably produces a full 32-char
secret while staying dependency-free.
Fixes#2660
vikunja's nfpm.yaml packs ./config.yml.sample as /etc/vikunja/config.yml.
The release-binaries action already regenerates it for the zip bundles,
but release-os-package runs on a fresh runner without that file, so
nfpm aborted with "matching ./config.yml.sample: file does not exist"
on every vikunja os-package matrix shard (the veans shards skip this
step entirely).
Add a vikunja-only step to regenerate it before nfpm runs.
The s3-action expands the upload glob into paths without a leading
"./", but strip-path-prefix was set to "./dist/os-packages/" (or
"./veans/dist/os-packages/"). The prefix never matched, so packages
landed at /<project>/<version>/<project>/dist/os-packages/<file>
instead of /<project>/<version>/<file>.
Drop the "./" prefix to match the working DIST_PREFIX pattern in
release-binaries.
The release-binaries and release-os-package composite actions were
comparing the raw release-version input against the literal "main" to
decide whether to use "unstable" for filenames and the S3 directory.
Callers always pass `steps.ghd.outputs.describe`, which is a value
like `v2.3.0-408-ge053d317` on non-tag builds — so the check never
fired and unstable artifacts landed under `/<project>/<describe>/`
with `<project>-<describe>-...` filenames.
Drive the switch from `github.ref_type == 'tag'` instead, matching the
pattern the desktop and config-yaml jobs in release.yml already use.
The raw describe value still flows into RELEASE_VERSION so the binary
and package metadata keep the precise commit reference.
GitHub's action manifest parser evaluates `${{ ... }}` expressions inside
`description:` block scalars, and `secrets` isn't a valid context in a
composite action — so the literal example text in the docstring caused
manifest validation to fail before any step ran.
build_mage_bin is only consumed by publish-repos in release.yml, so it
doesn't belong in the test workflow. Move it to release.yml as a
prep job and add it to publish-repos's needs list.
Reviewer asked us to stop over-configuring the release-binaries and
release-os-package composite actions — they're called only with
vikunja or veans, so per-project paths, artifact names, cache keys, S3
target, and version-or-unstable can all be derived inside the action
from the project name. The xgo-out-name input goes away too.
Vikunja-specific pre-build (downloading frontend_dist, generating
config.yml.sample) now happens inside the action, gated on the project
input. Callers no longer need those preamble steps.
Secrets stay as inputs — composite actions can't read \`\${{ secrets.* }}\`
directly; passing them through is the simplest workaround.
Each callsite shrinks to ~13 lines of mostly-secret pass-through plus
2-4 lines of real parameters.
The release pipeline lives entirely in build/magefile.go now, so the
per-project Release namespaces in vikunja's magefile.go and
veans/magefile.go are dead weight. Drop them.
Update the Dockerfile in the same commit so the apibuilder stage
invokes `cd build && mage release:xgo vikunja <target>` — the parent
magefile no longer exposes that target.
Replace the inline bodies of binaries, veans-binaries, os-package, and
veans-os-package jobs with calls to the new release-binaries and
release-os-package composite actions. Each call site is now ~25 lines
of inputs instead of ~75 lines of duplicated mage+upx+gpg+s3 plumbing.
publish-repos switches from the parent's ./mage-static to the
prebuilt build_mage_bin artifact so it can drive build/'s repo metadata
targets inside the publish-repos containers.
publish-repos runs inside ubuntu/fedora/archlinux containers that don't
ship a Go toolchain. Compile build/magefile.go into a static binary in
the test workflow (mirroring the existing mage_bin job for the parent
magefile) and upload it as the build_mage_bin artifact so publish-repos
can chmod+x and run it without setup-go.
Two reusable composite actions wrap the CI side of the release pipeline:
- release-binaries: setup-go, install mage + upx, cache xgo, invoke
`mage release:build <project>` from build/, GPG-sign the zip bundles,
upload to S3, store binaries and zips as workflow artifacts.
- release-os-package: download a binaries artifact, install mage,
`mage release:prepare-nfpm-config <project> <arch>`, stage the binary,
nfpm pack (with rpm signing inline and archlinux signing after), upload
to S3, store the package as an artifact.
Both actions are parameterized on project name, output paths, artifact
names, S3 target, and GPG/S3 secrets — adding a third Go binary to the
monorepo just means defining its project in build/magefile.go and adding
a four-line call site in release.yml.
New build/ Go module hosts the full release pipeline (xgo cross-compile,
upx, sha256, zip bundles, nfpm templating, deb/rpm/apk repo metadata)
for every Go binary in the monorepo. Parametric on project name —
`mage release:build vikunja` and `mage release:build veans` both flow
through the same code.
The module is intentionally self-contained: it depends on nothing but
stdlib + mage, and duplicates the small filesystem helpers (copyFile,
moveFile, sha256File) rather than importing them from a project
magefile. That keeps the release tooling free to evolve without
touching project code.
Cross-compile veans for the same OS/arch matrix as the main vikunja
binary, wrap each into a signed zip, build deb/rpm/apk/archlinux
packages via nfpm, and merge those into the existing dl.vikunja.io
package repos so `apt install veans` works from the same source.
- veans/magefile.go: Release namespace (xgo cross-compile, upx, sha256,
per-target zip bundle, nfpm.yaml templating).
- veans/nfpm.yaml: minimal — binary at /usr/local/bin/veans, no service
or postinstall.
- .github/workflows/release.yml: veans-binaries + veans-os-package
jobs, veans artifacts merged into publish-repos and create-release.
S3 layout mirrors vikunja under /veans/<version>/.
Resolves a medium-severity XSS in Address6 HTML-emitting methods
(GHSA / Dependabot alert #224). Vulnerable range: <=10.1.0,
patched in 10.1.1. The package is pulled in transitively through
socks -> socks-proxy-agent in the Electron build chain
(devDependency only), but we add a pnpm override to ensure the
patched version is used everywhere. The frontend workspace already
has the equivalent override.
Resolves Dependabot alert #233: qs.stringify crashes with TypeError on
null/undefined entries in comma-format arrays when encodeValuesOnly is
set (DoS, medium severity).
Updates transitive dependency via pnpm update from 6.15.0 to 6.15.2.
Adds a pnpm override for `tmp` in both the `frontend` and `desktop`
workspaces to force the patched version (0.2.6). The previous transitive
resolutions (`tmp@0.0.33` via external-editor in frontend, `tmp@0.2.3`
via tmp-promise in desktop) are vulnerable to a path traversal via
unsanitized prefix/postfix that enables directory escape.
Addresses Dependabot alerts #234 (desktop) and #235 (frontend).
Three helpers I added recently have no e2e coverage because the
suite always passes --bot-username with a valid name and
--yes-buckets to skip prompts.
Nine tests in a new bootstrap_test.go:
- TestValidateBotUsername — table-driven, 18 rows: valid shapes
(bot-foo, bot-foo-bar, bot-foo123, bot-foo_bar, bot-foo.bar,
bot-a), invalid prefix (foo, Bot-foo, ""), invalid chars
(spaces, commas, uppercase, !, embedded space), the reserved
link-share-N pattern, and the bare "bot-" edge.
- TestConfirmOverwriteExistingConfig — file-missing path, the
OverwriteExistingConfig=true short-circuit, every interesting
prompt answer (y, yes, Y, Yes, " yes " → proceed; n, "",
garbage → CodeConflict with path in message; prompter error
→ CodeUnknown wrapping the original via errors.Is).
- TestBootstrapBuckets_{AllPresent,AutoApprove,PromptDeclined,
PromptAborted,PromptUnknownCap,PromptAccepted} — drive the
function against a stub httptest server (bucketServer helper)
that records ListBuckets responses and CreateBucket payloads,
with a scripted queuePrompter for the prompt-driven cases.
Covers the alias-match short circuit, the auto-approve path,
the new declined/aborted/retry-cap paths, and the y-accepted
path.
Local helpers (queuePrompter for scripted answers with injectable
error; bucketServer for the stubbed bucket endpoints) stay in the
test file — no production code changes.
The e2e suite bypasses the OAuth flow via --token, so the callback
handler's error branches had zero coverage. Eight tests appended to
oauth_test.go drive the handler directly:
- happy path: code+state arrive on the channel; response is HTML
- authz-server error path: ?error=access_denied&error_description=…
bubbles up as a non-nil err containing the description (not the code)
- only-code fallback: when error_description is missing, the error
message falls back to the error code
- empty code: handler captures it; waitForCallback's job to reject
- non-GET method: 405 with Allow: GET, nothing pushed to channel
(defense against forged POST from a same-origin page)
- wrong path: 404, nothing pushed
- HTML-escaping: an error containing <script>…</script> renders as
<script> — XSS regression guard
- nil-err success page: 200 with 'veans is authorized'
Plus generateState shape coverage (length, charset, uniqueness)
to match the existing TestGeneratePKCE_*.
Sanity-checked the XSS test by deleting the html.EscapeString call —
it fails with raw <script> in the body. Restored.
internal/client/ had no coverage for the helpers that turn HTTP
responses into the stable error envelope. e2e exercises happy paths
but never asserts the envelope's Code field for each status, so a
refactor of mapHTTPError could silently drift.
Seven tests in client_test.go:
- TestMapHTTPError_StatusCodeMapping table-drives 401/403/404/409/
429/400/422/500 → the right output.Code constants.
- TestMapHTTPError_RetryAfterAppendedToMessage asserts the
(retry-after <dur>) suffix on 429s.
- TestMapHTTPError_BodyTruncation pumps 600 bytes and asserts the
message ends with …(truncated) and Cause stays nil (per the earlier
'drop synthetic Cause' change).
- TestMapHTTPError_VikunjaJSONTakesPrecedenceOverRawBody asserts the
parsed {code,message} payload wins over the raw body for the
embedded message text.
- TestParseRetryAfter handles delta-seconds, HTTP-date forms (with a
tolerance window because the parser uses time.Until), and the
unparseable/empty/negative/past-date cases.
- TestPaginationDone covers the header-authoritative and len-heuristic
branches across full, short, and empty pages.
- TestCreateBotUser_404TranslatesToBotUsersUnavailable drives a
fake httptest server returning 404 on PUT /api/v1/user/bots and
asserts the error code is BOT_USERS_UNAVAILABLE (the translation
lives in users.go:37-42).
internal/output/ had zero unit tests; the envelope shape it produces
is consumed by every agent integration on the other side of stdin, so
locking it down with a small test file is high-leverage.
Six tests in errors_test.go:
- TestAsError_Nil, TestAsError_PreservesKnownCode,
TestAsError_UnwrapsThroughFmtErrorf,
TestAsError_PlainErrorBecomesUnknown — pin AsError's contract
against nil / direct / wrapped / plain inputs.
- TestEmitError_EnvelopeShape — round-trips through bytes.Buffer and
asserts exactly two keys ("code", "error"), correct values, and a
trailing newline.
- TestWrap_PreservesCauseForErrorsIs — confirms errors.Is and
errors.As walk through Wrap so future sentinel introductions work.
A comment block documents why EmitError's encode-failure fallback
isn't exercised — json.Marshal of {Code, Message} cannot fail, so the
branch is unreachable from outside the package.
Four unit tests in internal/credentials/file_test.go for behaviors that
have no e2e coverage (e2e exercises file-backend writes round-trip but
never stats the mode, never simulates a crash, never races two
processes, never observes the fallback warning):
- TestFileBackend_SetReassertsMode pre-creates the file at 0o644 and
asserts Set narrows it to 0o600 via Chmod-after-Rename.
- TestFileBackend_SetCleansUpTmpFile scans the dir after Set and
fails on any leftover .credentials-*.tmp.
- TestFileBackend_ConcurrentWritersSerialize runs two goroutines
writing distinct keys; both records must survive (verifies the
flock around load-mutate-save).
- TestChain_SetWarnsOnFallback captures ChainStderr via bytes.Buffer
and asserts the one-line warning when a writable backend errors
before the file backend succeeds.
The two ordering rules in commands/update.go::runUpdate aren't enforced
by anything beyond the lines being written in that sequence:
1. MoveTaskToBucket runs AFTER UpdateTask, so a status transition
doesn't clobber freshly attached labels.
2. The scrapped-reason comment posts BEFORE the bucket move, so the
audit trail reads chronologically.
Both are documented in CLAUDE.md but neither is exercised by the e2e
suite: TestUpdate_DescriptionReplaceUniqueness is the only update-side
e2e and it only covers --description-replace-old/new.
Add two unit tests that drive runUpdate against an httptest.Server and
assert the exact (method, path) sequence. Sanity-checked locally by
swapping the field-update and bucket-move blocks — both tests fail with
a clear order diff, confirming they catch the regression that's most
likely to slip through review.
A keyring transient failure on Set silently falls through to the file
backend today, which leaves a stale keyring entry from any prior
successful write shadowing the new file-backend token. Fixing the
shadow itself is deferred (would need a Set-and-Delete coordination,
or a stricter contract).
What we can do cheaply: surface the fallback so an operator hitting
the shadow has a breadcrumb. On Chain.Set fallthrough past a writable
backend that errored, print:
veans: credential store: keyring rejected write (X); falling back to file
The warning goes to stderr (not the structured envelope — Set still
returns nil because the write landed somewhere). Env-backend's
read-only skip is unchanged and silent.
ChainStderr is exposed as a package var so tests can capture/assert
the warning when we backfill credential-store coverage.
The 30s timeout on the client.New HTTPClient was hard-coded and
opaque. Long-running paginated reads against slow networks were
tripping it with no escape hatch.
Lift the value into a named constant and let .veans.yml override it
via a new optional http_timeout field (Go duration syntax, e.g.
"60s", "5m"). The field has omitempty so a freshly-written
.veans.yml from `veans init` doesn't surface the knob — operators
who need to tune it can hand-edit, but it stays out of the way for
the common case.
Runtime loader applies the override after client.New if set;
bootstrap- and login-time clients (built before .veans.yml exists)
keep the default.
The project picker used to require at least one pre-existing project
and would otherwise hard-error: "no projects visible to this user —
create one in the Vikunja UI first". Now it always offers an extra
numbered entry "Create a new project" and, when the user picks it,
prompts for a title (required) + identifier (optional). Empty-list
case routes straight to creation.
Backed by a new client.CreateProject(ctx, *Project) method (`PUT
/projects`); the e2e harness now uses that instead of the raw c.Do
call it did before.
Also fixed a latent bufio bug in StdPrompter.ReadLine that this work
surfaced: every call created a fresh bufio.Reader, which read-ahead a
buffer and threw it away on return. Second+ prompts read empty. Reuse
one buffered reader on the StdPrompter instance.
The previous init flow took whatever the user typed for --server and
called GET <url>/api/v1/info on it. If the user typed
"vikunja.example.com" (no scheme), or pasted the URL with /api/v1 in
it (double-suffix), or pointed at a localhost install on the default
:3456 port without typing the port, we'd hand back a raw HTTP error.
New `client.DiscoverServer` ports the frontend's
helpers/checkAndSetApiUrl.ts discovery: probe a small ordered set of
plausible bases for /api/v1/info, return the first one that returns
parseable Info. Candidate order:
1. scheme://host[:port]/path (as the user typed it)
2. scheme://host:3456/path (default API port)
3. opposite scheme of (1)
4. opposite scheme of (2)
Heuristics:
- Missing scheme → https for public hosts, http for localhost /
127.0.0.1 / [::1] (matches most CLIs' behaviour)
- Trailing /api/v1 from a pasted URL is stripped before probing, so
we don't double up to /api/v1/api/v1/info
- Trailing slashes normalized
Errors now list everything we tried + the last underlying network
error, so the user can see why a URL failed instead of just
"GET /info: connection refused":
veans: VALIDATION_ERROR: couldn't find a Vikunja instance reachable
from "vikunja.example.com" — tried:
- https://vikunja.example.com/api/v1/info
- https://vikunja.example.com:3456/api/v1/info
- http://vikunja.example.com/api/v1/info
- http://vikunja.example.com:3456/api/v1/info
last error: dial tcp: lookup vikunja.example.com: no such host
bootstrap.Init now defers URL canonicalisation to DiscoverServer and
caches the matched info from the probe (no second /info round-trip).
Unit tests cover the candidate-builder across the common shapes:
bare hostname, localhost, /api/v1-suffixed paste, explicit port,
subpath install, 127.0.0.1:3456, trailing slash. e2e green.
Adds a final step to bootstrap.Init that offers to wire `veans prime`
into Claude Code and OpenCode automatically. Per-agent yes/no prompts
default to "yes" for Claude Code and "no" for OpenCode; --install-claude
/ --install-opencode flags skip the prompt for scripted contexts;
--no-hooks falls back to the previous behaviour of just printing the
snippets.
Claude Code:
- Writes/merges .claude/settings.json
- JSON merge preserves existing keys (model, permissions, other hooks)
and only appends a `veans prime` command entry under SessionStart
and PreCompact if one isn't already there
- Idempotent: re-running reports "Already configured" without
duplicating entries
OpenCode:
- Writes .opencode/plugin/veans-prime.ts with the standard handler
skeleton
- Existing files are left alone (no TS-merge story for v0)
Failures during hook install are non-fatal: the repo is already
configured, so the user gets a warning + the printed snippets as a
fallback path.
Unit tests cover the merge logic (fresh file, idempotent rerun,
preserving user's other hooks/keys), the install actions
("Wrote"/"Updated"/"Already configured"), and the offer flow
(flags-bypass-prompt vs prompt-when-unset vs no-hooks).
Captures the non-obvious things an agent will hit working on this
submodule:
- Wire-format quirks (view_kind/bucket_configuration_mode are JSON
strings; Task.BucketID is always 0 in GET — use ?expand=buckets and
CurrentBucketID; POST /tasks doesn't move buckets, use the dedicated
bucket-tasks endpoint; bot creation is at /user/bots; APIToken
expires_at is required, use FarFuture for "no expiry").
- Permission discovery via /routes (group names are path-derived; use
PermissionsForBot at runtime instead of hard-coding).
- OAuth shape (PKCE/S256 mandatory, no client registration, JSON-only
token exchange, loopback redirect via 127.0.0.1:0, Shutdown uses
context.WithoutCancel to drain on outer cancel).
- Credential chain order + per-test HOME/XDG override.
- Identifier validation (runelength only) + base-36 timestamp suffix
trick for unique e2e identifiers.
- mage Aliases map (without it, `mage test` rejects the namespace).
- License-header enforcement via local .golangci.yml + code-header-
template.txt copy.
- Things to actively avoid: bare exec.Command, committing the built
binary, stdout from `prime` outside a configured workspace.
CLAUDE.md is a symlink to AGENTS.md so Claude Code picks it up via
either name.
Vikunja's built-in OAuth server (Vikunja 2.3+) does not require client
registration and accepts arbitrary client_ids — it just enforces PKCE
(S256) and constrains redirect URIs to the vikunja- scheme. Earlier I
deferred OAuth on the assumption it needed a registered client; that
was wrong, and the docs make the path much smoother than POST /login.
The custom-scheme constraint (no http:// loopback) is side-stepped by
manual paste-back: veans prints the authorize URL, the user signs in,
their browser fails to open vikunja-veans-cli://callback?code=... and
shows an error, the user copies the URL from the address bar and
pastes it back. CLI extracts code + state, verifies state for CSRF,
exchanges via POST /api/v1/oauth/token (JSON body — Vikunja rejects
form-encoded), and returns the access token.
Resolution order in AcquireHumanToken:
1. --token (paste-in JWT or personal API token; SSO/OIDC users)
2. --use-password / --username + --password (POST /login)
3. OAuth flow (interactive default)
login command supports the same --use-password / --token escape hatches
for token rotation on instances with OAuth disabled.
Includes unit tests for the PKCE generator (verifier shape per RFC 7636,
challenge = SHA256(verifier) base64url-no-pad), authorize-URL
construction, and the lenient callback parser (full URL / query-only /
bare code).
Drives the reply flow through the browser: existing comment is
quoted via the Reply action, the prefilled blockquote round-trips
to the saved reply, the chevron jumps back to the original and
applies the brief highlight.
Each rendered comment gets a "Reply" action (shown whenever the
viewer has write access, regardless of authorship). Clicking it
prefills the comment editor with a <blockquote data-comment-id="X">
wrapping the parent body so the canonical reply marker is the
blockquote itself.
A Vue NodeView on the blockquote extension renders an author
header + chevron when an injected commentReplyContext can resolve
the parent. The chevron scrolls to and briefly highlights the
original. Quotes whose parent isn't in the in-memory list (deleted,
on another page) render a degraded header with the chevron hidden.
Extend the default Blockquote with a `commentId` attribute that
round-trips through HTML as `data-comment-id`. This single attribute
is the canonical record of a reply: it survives TipTap serialize /
parse so the backend listener and the in-app renderer can both find
the parent comment without a separate schema field.
A comment whose body contains <blockquote data-comment-id="…"> nodes
now triggers the same task-comment mention notification for the
quoted comments' authors, respecting CanRead, subscription, and
existing dedup. Self-quotes, wrong-task quotes, and malformed ids
are silently skipped.
el-GR translations are around 36% complete but were not yet listed in the
UI. Add it to the supported locales list (frontend and backend) and wire
up the dayjs locale mapping.
A <dialog> opened via showModal() lives in the browser's top layer, which
renders only on the first page during print — top-layer elements are
viewport-anchored and don't paginate. CSS overrides like position: static
have no effect since top-layer membership is browser-managed.
Swap to a non-modal dialog on beforeprint (removes it from the top layer
so content flows in normal document order) and back to modal on
afterprint. The accompanying @media print rules reset the dialog's fixed
positioning and overflow so the non-modal dialog can paginate freely.
Adds a pnpm override to force postcss to a patched version (>=8.5.10),
removing the vulnerable postcss@7.0.39 pulled in transitively by
postcss-easing-gradients. Resolves GHSA / Dependabot alert #197.
Adds a pnpm override to pull ip-address >=10.1.1, resolving the XSS
vulnerability in Address6 HTML-emitting methods (GHSA, dev-only
transitive dependency via puppeteer/socks).
Resolves GHSA path traversal via percent-encoded dot segments and host
confusion via percent-encoded authority delimiters (Dependabot alerts
227 and 228). fast-uri is a transitive dev-only dependency via
stylelint -> table -> ajv.
Seed the dedup map at the start of insertFromStructure with the importing
user's existing labels, keyed by title + normalized hex color. Previously
the map was empty on each run, so importing the same CSV (or any other
migration format) twice would create a second copy of every label.
Scoped to the user's own labels so imports don't silently link to other
users' labels visible via shared projects.
Fixes#2742
Switches the input normalisation from lower- to uppercase so identifiers
canonicalise the same way GitHub-style refs do (e.g. "PROJ-42"). The
positive identifier tests are dropped for now because the existing
fixtures store identifiers as lowercase ("test1") and the SQL comparison
remains case-sensitive — once the column-side case-insensitive match
lands, full coverage can be reinstated.
Normalises the input side so GitHub-style references like "TEST1-42" and
"test1-42" resolve to the same project. The SQL comparison itself remains
case-sensitive for now; case-insensitive matching on the column will be
addressed separately.
Allows GET /projects/{project}/tasks/by-index/{index} to resolve {project}
as either a numeric id or a project identifier (e.g. "PROJ"), so callers
can build GitHub-style task references like "PROJ-42" without first
looking up the project's numeric id. Pure-digit values remain interpreted
as ids, which makes identifiers consisting solely of digits unreachable
via this route.
The .avatar img in User.vue relied solely on the width/height HTML
attributes for sizing. Those are presentational hints with zero CSS
specificity, so Bulma's global reset (img { height: auto; max-width: 100% })
overrode them. While avatarSrc was still resolving (initial src=""),
the browser had no intrinsic dimensions to compute the auto height from
and fell back to the broken-image box (~96px in Chrome), then snapped
to the real size once the blob URL loaded.
Set inline-size/block-size explicitly via a CSS custom property bound
to the avatarSize prop so the rendered size is locked regardless of
load state or the Bulma reset.
The conversational mail template does not reference cid:logo.png, but
RenderMail still attached the embedded logo to every outgoing mail.
That left an orphan inline part that some clients render as a stray
attachment. Only embed logo.png when the formal template is in use.
Tooltips on relative dates (and other content) were invisible when a task
was opened in the modal. The modal uses <dialog> opened via showModal(),
which places it in the browser's top layer. floating-vue teleports
tooltips to <body> by default, so they were rendered *below* the dialog
backdrop and hidden behind it.
Wrap the v-tooltip directive to detect the nearest <dialog> ancestor of
the target and use it as the tooltip's container, keeping the tooltip in
the same top-layer context as the modal it belongs to. Tooltips outside
any dialog still teleport to <body> as before.
The Crowdin sync workflow used `git diff --quiet` and `git commit -am`,
both of which only consider tracked files. New language files downloaded
by Crowdin (e.g. el-GR, th-TH) were therefore left untracked and silently
dropped on each run.
Switch the change check to `git status --porcelain` scoped to the
translation directories and stage them explicitly before committing so
new locales are included.
Add packageRules to keep mise.toml in sync with the files it mirrors
when Renovate raises version-bump PRs:
- node: groups mise.toml and frontend/.nvmrc (nvm manager) into one PR
- pnpm: groups mise.toml and frontend/package.json#packageManager
(npm manager) into one PR
Without these rules Renovate would open separate PRs for each file,
allowing them to drift out of sync.
Consolidates tool versions already declared across the project into a
single mise.toml so that `mise install` / `mise exec` activates the
correct runtime in one step.
Without an explicit project-level pin, mise falls back to the global
user config, silently using the wrong version even when .nvmrc is
present (legacy files rank below all mise config files).
Versions mirror existing project pins:
- node 24.13.0 (frontend/.nvmrc)
- pnpm 10.28.1 (frontend/package.json#packageManager)
- go 1.25.7 (go.mod)
The sticky bucket footer had no z-index, so the absolutely positioned
`.handle` overlays on each task (z-index: 1, used to capture taps on
touch devices) stacked above the Add Task button. Tapping the button
where a task scrolled behind it would open that task instead of opening
the new-task input.
Route the create flow through taskStore.createNewTask so titles typed
into the related-task input get parsed for labels, priority, assignees,
due dates and cross-project targets - matching the main add-task input.
Also surface the quick-add-magic hint next to the field.
Hardcoding the three exact strings localhost / 127.0.0.1 / ::1 rejected
legitimate loopback redirects like 127.0.0.2:1234 (anywhere in 127.0.0.0/8)
or [0:0:0:0:0:0:0:1]:1234 (expanded IPv6 loopback). Use net.IP.IsLoopback()
to cover the full loopback ranges, and match "localhost" case-insensitively.
0.0.0.0 stays rejected as it is not a loopback address.
https://claude.ai/code/session_01LsTDrCJ7trE6WQ4FYf78UB
Previously the OAuth server rejected every redirect_uri that did not start
with a vikunja- custom scheme. Native apps that cannot register a custom
scheme (e.g. CLIs, desktop tools) need loopback redirects per RFC 8252, so
also allow http://localhost, http://127.0.0.1 and http://[::1] (any port).
Non-loopback http:// and https:// targets remain rejected.
https://claude.ai/code/session_01LsTDrCJ7trE6WQ4FYf78UB
TaskAttachment.ReadOne now swallows ErrAccountDisabled/ErrAccountLocked
from the creator lookup, matching the existing ErrUserDoesNotExist
swallow. Without this, deleting a disabled user that owned a project
with task attachments would fail when the cascade re-loaded the
attachment to delete it.
The pseudo-element that extends the checkbox hit target also covered
label text content, which broke Playwright actionability checks for
clicks on text inside wider FancyCheckbox labels (e.g. the "Show
Archived" toggle on the projects list page).
Move the rule out of BaseCheckbox and into SingleTaskInProject's deep
override, where the label slot is already hidden via display: none, so
no neighboring content can be intercepted.
A pseudo-element on the label provides a 44x44 minimum hit area
centered on the visible icon. Visible size and surrounding layout
are unchanged. Addresses misclicks on the task list view checkbox
where ~50% of taps would open the task detail instead of toggling
done.
The flatpickr time inputs hardcoded `time_24hr: true`, so users who
selected the 12-hour format in their settings still got a 24-hour
picker — even though the displayed dates respected the preference.
Bind `time_24hr` to the existing `useTimeFormat` composable in:
- DatepickerInline (start/end/due dates and absolute reminders)
- DeferTask (defer due date)
- ApiTokenForm (API token expiry)
Reported at https://community.vikunja.io/t/4492.
Removes the `service.enablebotusers` config flag, the matching
`bot_users_enabled` field on /info, and the now-unused
`ErrBotUsersDisabled` error. Bot user routes and the frontend
settings tab are now always available.
https://claude.ai/code/session_01VhAR6xnoCdG1fpX52bzaCC
User search previously filtered bots only when they happened to match the
search string. That produced two bad behaviours:
1. Bots owned by other users could surface on an exact-username match,
leaking them into assignee pickers and similar UI.
2. A user could not reliably find their own bots by typing a partial
name, so bots became awkward to assign to tasks.
Change ListUsers to treat bot ownership explicitly: the existing match
branch excludes rows owned by someone else, and a second branch always
returns bots owned by the calling user. The own-bots branch also
respects any AdditionalCond passed in so project-scoped listings don't
start leaking bots from outside the project.
Bot users now render with a cool-toned (blue/cyan/violet/teal/indigo)
marble variant so they're visually distinguishable from human users.
Marble's rendering logic is parameterized with a palette; the route
forces the bot palette whenever the resolved user is a bot, overriding
whatever avatar provider they'd otherwise inherit.
The JWT skipper bypassed validation entirely for /token/test when the
bearer was an API token, leaving "user" unset in the context. CheckToken
then type-asserted it to *jwt.Token and panicked.
Validate the API token in the skipper but skip the route permission
check (since /token/test is not exposed in the API token route registry,
no token can hold explicit permission for it). Drop the now-redundant
JWT assertion in CheckToken — auth has already passed by the time the
handler runs.
The Modal mounts the <dialog> via v-if and calls showModal() in a
follow-up flush, so v-focus runs while the dialog is still closed and
its focus() call is dropped. The existing rAF retry was gated on
quick-add mode, leaving Ctrl+K in the regular app with no focused
input. Run the retry whenever the quick actions become active and keep
the command pre-selection scoped to quick-add mode.
Stylelint 17.9.0 resolves customSyntax modules relative to the
stylelint package, so the transitive postcss-html pulled in via
stylelint-config-recommended-vue is no longer reachable and lint
fails with "Could not find postcss-html".
During E2E testing with Playwright (via `mage test:e2e`), the frontend is built using Vite with `--mode development`. However, Vite hardcodes `process.env.NODE_ENV` to `production` during the build step, which causes `import.meta.env.DEV` to statically evaluate to `false`.
Because the `v-cy` custom testing directive relied on the `DEV` flag, it silently evaluated to false and failed to render the `data-cy` attributes into the DOM during the test build. This caused test failures because Playwright could not locate the elements.
Changing the check to explicitly evaluate `import.meta.env.MODE === 'development'` successfully bypasses the Vite build behavior, ensuring that `data-cy` testing attributes are consistently rendered during E2E tests.
For more context on Vite's build behavior regarding `DEV` and development mode, see:
https://github.com/vitejs/vite/discussions/14083
Mouse event emulation from taps on touch devices caused the glance
tooltip to appear unexpectedly with no reliable way to dismiss it.
Gate the tooltip behind a `(hover: hover) and (pointer: fine)` media
query so it only activates on devices with a real pointer.
Point agents to the skills up front so they are loaded before code is
written in the relevant areas. Patterns like permission placement and
migration error handling have been documented for 5+ months but still
recur in review; a path-aware skill prompt is a stronger trigger than
guidance buried further down the file.
Checklist skill invoked before editing files under pkg/migration/. Covers
cross-DB type safety across MySQL/PostgreSQL/SQLite, DDL error handling
(no silent discards), time-column conventions, path sanitization for
user-supplied input, and model/frontend sync requirements.
Checklist skill invoked before editing models in pkg/models/. Covers Can*
method placement (on the model, never in route handlers), the four
permission methods, required positive+negative test coverage, and the
anti-patterns most frequently flagged in review.
The api translation scanner only looked at literal arguments to i18n.T /
i18n.TP, so keys passed via a variable (e.g. the time.since_* keys stored
in a struct slice in pkg/utils/humanize_duration.go and looked up via
chunk.key) were invisible and had to be hard-coded in an allowlist of
dynamic prefixes.
Mirror the frontend scanner: collect every dotted string literal in the
Go source as a "usage hint" and treat any literal that matches a known
translation key as used. This automatically picks up the time.since_*
case and removes the need for the apiDynamicKeyPrefixes allowlist.
Previously the PR introduced a separate `check:frontendTranslations` mage
task and a second CI job. Merge both into the existing `check:translations`
task and a single CI job. Also rename internal references from "backend" to
"api" to match the project convention (Vikunja's Go server is the api, not
the backend).
Add a frontend-check-translations job that runs the new
check:frontendTranslations mage task. Like the existing
api-check-translations job, failures hard-fail CI. This makes
reviewers catch dead keys and missing $t() wiring up front instead of
having to flag them manually in pull request review.
Remove 47 keys from frontend/src/i18n/lang/en.json that are not
referenced by any $t / t / i18n.t / i18n.global.t / tc / <i18n-t>
call site, nor by any stored-as-literal dynamic lookup pattern.
The keys fall into a few broad groups: leftover attribute labels on
filter and label models, dropped editor toolbar entries, unused
password/password-confirmation copy, and a handful of stale admin and
migration strings. The sibling translation files will be reconciled on
the next Crowdin sync.
Remove five keys from pkg/i18n/lang/en.json that are no longer
referenced by any i18n.T / i18n.TP call. These surfaced once the
translation check started reporting dead keys. The sibling translation
files will be reconciled on the next Crowdin sync.
Removed keys:
- notifications.task.comment.mentioned_message
- notifications.task.mentioned.message
- notifications.common.actions.assigned_you
- notifications.common.actions.assigned_themselves
- notifications.common.actions.assigned_user
The call to i18n.T for notifications.task.overdue.overdue was missing
its first positional argument, so the translation key was being passed
as the language code. This surfaced as a "dead key" once the
translation check learned to look for unused entries. Fix the call so
the reminder line is properly localised.
Extend the existing check:translations task so it now also reports
"dead" keys - entries present in pkg/i18n/lang/en.json but never
referenced by any i18n.T / i18n.TP call. Dynamic references (where
the key is a runtime value, e.g. from a struct field) are handled via
an allowlist of prefixes so they don't false-positive.
Add a new check:frontend-translations task that performs the same
bidirectional check against frontend/src/i18n/lang/en.json by scanning
.vue / .ts / .js files for $t, t, i18n.t, i18n.global.t, tc, $tc calls
and <i18n-t keypath="...">. Template literals with ${...} interpolation
contribute a usage prefix instead of a single key. String literals that
exactly match a known translation key (or template-literal prefixes
assigned to a variable) are also treated as usage hints, so keys stored
in arrays or built up programmatically aren't flagged as dead.
Register the new task in Check.All so `mage check` covers both.
Chromium already caps zoom levels internally, so the manual [-7, +7]
clamp was redundant. Removes the constants and clamping logic while
keeping the before-input-event approach intact for persistence support.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Electron does not register zoom shortcuts by default, so the desktop app
had no way to scale the UI. Register Ctrl+Plus, Ctrl+Minus and Ctrl+0
via webContents.before-input-event, and Ctrl+scroll via the zoom-changed
event.
The zoom level is clamped to [-7, +7] (Chromium's range, roughly 28% to
358%) and persisted to zoom.json in app.getPath('userData'), so the
chosen level survives restarts. Restored zoom is re-applied on every
did-finish-load, since Electron resets zoom across page reloads.
Fixes#2623
Wire aria-invalid, aria-describedby and role=alert on the form
primitive components so errors raised directly on FormInput or
FormSelect are announced by assistive tech and programmatically
linked to the control.
Adds aria-label='Mark {task} as done' to task checkboxes so
screen readers can distinguish between them. Passes ariaLabel
prop through FancyCheckbox → BaseCheckbox → input.
Fixes WCAG 2.4.6 (Headings and Labels).
Adds aria-invalid, aria-describedby, and role='alert' to error
messages in FormField and Password components so screen readers
announce validation errors.
Fixes WCAG 3.3.1 (Error Identification).
Passes aria-label to the <dialog> element via attribute inheritance
so screen readers announce the dialog's purpose.
Fixes WCAG 4.1.2 (Name, Role, Value).
Adds role='status' and aria-live='polite' to the notification
container so screen readers announce status messages.
Fixes WCAG 4.1.3 (Status Messages).
Adds a visually-hidden skip-to-content link as the first focusable
element. Adds id='main-content' to the <main> element. Changes
<section> to <main> on auth pages for proper landmark navigation.
Fixes WCAG 2.4.1 (Bypass Blocks).
CI shard 4 hit a ~996ms skew between the JS-constructed originalDue and
the backend's advanced due date, enough to bust the <500ms precision
bound. Bump precision to -4 (<5s) — still tight enough to confirm the
regeneration advanced by ~1 day, loose enough to absorb sub-second
round-tripping through Date → ISO → Go time.Time → JSON.
Keeps the Do* helpers framework-neutral so non-Echo callers (upcoming
Huma /v2 handlers) don't need a translation shim.
Addresses review feedback on #2670.
The Bulma form/checkbox-radio partial only defined two selectors: .checkbox
(consumed exclusively by FormCheckbox.vue) and .radio (consumed by
ViewEditForm.vue and user/settings/Avatar.vue). Ports the %checkbox-radio
placeholder rules (cursor, line-height, position, hover/disabled states,
and the input cursor override) into FormCheckbox's scoped style for the
.checkbox side, and into scoped style blocks on the two remaining .radio
call-sites for the .radio side (including the 0.5em sibling margin via
margin-inline-start). Drops the now-unused @import. Pixel-perfect verified
on /login, /user/settings/general, and /user/settings/avatar: every
measured label/input getBoundingClientRect and computed style matches the
baseline exactly (0px deltas across all 5 sampled checkboxes and all 5
avatar radios).
GuardLastAdmin counted only active, non-deletion-scheduled admins, but gated only on target.IsAdmin. Demoting or deleting an already-disabled or deletion-scheduled admin would then be blocked whenever exactly one active admin remained, even though removing a user who isn't in the reachable set can't reduce the count. Return early when the target isn't part of the counted set.
On startup, if the license server was unreachable with no usable cached status, or the server rejected the key, we only logged a warning without clearing persisted license.state. On Redis/keyvalue deployments a previous run's Licensed=true could remain active even though pro features were advertised as unavailable. Route both paths through degradeToFree so the persisted state is cleared.
The last-admin guard was only enforced in the --now branch of 'user delete'. The default scheduled path called user.RequestDeletion without the guard, letting an operator schedule deletion of the last reachable admin via the CLI; the cron flow would then confirm and execute it, violating the invariant the HTTP admin API already enforces.
The admin create-user handler returned the in-memory newUser struct directly. On mail-enabled instances with skip_email_confirm=false, user.CreateUser persists the account as email-confirmation-required, but the returned struct still reflects the pre-persist status, so the admin API reported a misleading active status immediately after creation.
The Bulma partial applied justify-content: space-between on .pagination and
flex-order 1/2/3 on prev/list/next inside a tablet media query under
.is-centered — the port missed both. Pixel-diff against main is now zero.
BasePagination was reaching across slot boundaries with :deep() to style
.pagination-previous / -next / -link — markup it doesn't actually render.
Move that markup and the related scoped rules into a new PaginationItem
component that polymorphically renders RouterLink (when `to` is given)
or BaseButton (emit-based). BasePagination keeps only the scaffold it
actually owns: .pagination, .pagination-list, .pagination-ellipsis.
Pagination.vue and PaginationEmit.vue become thin wrappers around
BasePagination + PaginationItem; no more raw pagination-* class usage or
BaseButton imports in the emit wrapper.
The .app-container.has-background / .link-share-container.has-background
theme override moves with the .pagination-link rules into PaginationItem
as its own unscoped <style> block.
Result: 0 remaining :deep(.pagination-*) selectors (was 14).
The Bulma components/pagination partial is only used by BasePagination
and its two wrappers (Pagination.vue, PaginationEmit.vue); no other
component renders raw .pagination-* markup. Ports the rules we actually
use (base layout, item sizing, hover/focus/disabled states, is-current
styling, mobile/tablet breakpoints) into BasePagination's scoped styles,
using :deep() to reach slotted children. The theme override for
.pagination-link on .has-background containers moves into an unscoped
style block on the same component. Drops the now-unused @import.
The old setupTray() called tray.destroy() before creating a new Tray. On
Linux with KDE Plasma 6 Wayland, tray.destroy() does not actually remove
the icon from plasmashell (electron/electron#49517), so the new Tray
registers a fresh dbusmenu while plasmashell keeps talking to the
orphaned one. Menu items still render (cached layout) but every
com.canonical.dbusmenu.Event("clicked", ...) method call from plasmashell
hits the destroyed handler and is dropped, so menu-item click callbacks
stop firing after the frontend triggers a rebuild on login.
Move the one-time Tray construction (icon, tooltip, click handler)
behind a !tray guard and keep setContextMenu in the always-run path. The
desktop:update-quick-entry-shortcut IPC handler keeps calling setupTray()
and the accelerator label updates without touching the native Tray.
.navbar and .navbar-end are only used in AppHeader.vue. Ports the
relevant rules (z-index 30 and min-block-size: $navbar-height) into
its scoped <style> block and drops the partial import.
The .media / .media-left / .media-content classes are only used in
Comments.vue. Ports the relevant rules into its scoped <style> block
and drops the partial import.
Only .is-pulled-right is used (3 callsites); .is-pulled-left and
.is-clearfix from Bulma's helpers/float partial have zero usage. Ports the
one needed rule into theme/helpers.scss so the Bulma import can go.
typeof null === 'object', so null slipped past the type guards in
objectToCamelCase/objectToSnakeCase/prepareParams. The original
for...in loops silently iterated nothing on null; Object.keys(null)
throws. Also guard saveCollapsedBucketState where state[projectId]
may be undefined.
Replaces 33 for...in loops across 18 files with for...of + Object.keys/entries
or indexed for loops. for...in iterates enumerable string keys including
inherited ones, which is especially risky on reactive arrays (tasks, labels,
assignees, etc.) where polyfilled properties may appear.
Loops that mutate via splice during iteration now iterate backwards to avoid
index-shift bugs. Adds a no-restricted-syntax ESLint rule forbidding
ForInStatement to prevent regressions.
Closes#513
Every file input in the codebase is hidden (via class="is-hidden" or
scoped display:none) and triggered programmatically by a custom XButton.
None of Bulma's .file / .file-label / .file-cta / .file-input / .file-name /
.file-icon classes are used anywhere in .vue files, so the partial is dead
code.
Vikunja's Modal.vue uses a native <dialog> element with its own locally-
scoped classes (modal-dialog, modal-container, modal-content, modal-header).
None of Bulma's modal classes (.modal, .modal-background, .modal-card*) are
used anywhere in the app. The two CSS variables this partial provided
(--modal-card-head-padding, --modal-content-spacing-tablet) were inlined in
the two callers in the previous commits, so the whole partial is now dead
code.
Modal.vue already had several 'reset bulma' overrides fighting the default
rules Bulma applied to .modal-content; those can be cleaned up in a
follow-up.
The --modal-content-spacing-tablet CSS variable is provided by Bulma's
components/modal partial. Inlining Bulma's default (40px) lets us drop that
partial.
The --modal-card-head-padding CSS variable is provided by Bulma's
components/modal partial. Inlining Bulma's default (20px) lets us drop that
partial without needing a local redeclaration.
Call license.Init() after database initialization and before the web
server starts. Call license.Shutdown() during graceful shutdown to stop
the background check goroutine.
Implement the license validation system with:
- Server communication with retry logic and exponential backoff
- In-memory state management for feature flags and user limits
- Cached validation with 72h expiry stored in database
- Background goroutine with adaptive check intervals (24h/1h)
- Graceful degradation to community mode on failure
- Instance ID generation and persistence
Native <dialog> elements opened with showModal() render in the browser's
top-layer. Popups appended to document.body end up behind the dialog
regardless of z-index, which broke the slash-command menu and the user
mention suggestion inside the task detail modal.
Append the popups to the nearest open <dialog> ancestor of the editor
(falling back to document.body) so they join the same top-layer stacking
context.
Pacman verifies individual package signatures (.sig files). Add GPG
setup and detach-sign step for archlinux packages in the os-package
job. The .sig is uploaded alongside the package to S3.
S3 can't store symlinks. Previously all symlinks were deleted, which
removed vikunja.db -> vikunja.db.tar.gz needed by pacman. Now resolve
symlinks into real file copies first, then delete package files.
Add GPG signing configuration to nfpm.yaml for rpm packages. The
os-package job now sets up GPG and exports the key for nfpm to use
during package creation.
The dl.vikunja.io worker redirects package file requests under /repos/
to the existing artifacts, so uploading them again is redundant.
Remove .deb, .rpm, .apk, .archlinux, .pacman, .pkg.tar.zst files
and symlinks before uploading to R2.
The s3-action glob matched directories causing EISDIR errors. Fixed
the action to filter with fs.statSync().isFile(). Updated all
s3-action references to the new version.
The **/*.* glob skipped extensionless files like Release, InRelease,
and Packages. Revert to **/* and instead remove reprepro's internal
db/conf directories and directory symlinks before uploading.
Parse the top-level `attachments` array in WeKan board JSON exports,
group them by card ID, base64-decode the payload, and attach the
resulting files to the generated tasks so they land in Vikunja as
task attachments. Orphaned attachments (cardId with no matching card)
are silently skipped; decode errors are logged and skipped.
The mage-static binary is compiled with glibc which can't run on
Alpine's musl. Instead of fighting compatibility, inline the APK
repo generation as shell commands since the logic is simple.
- Install libc6-compat on Alpine so the glibc-linked mage binary runs
- Change S3 upload glob from **/* to **/*.* to skip directories
- Add debug step to inspect mage-static binary on Alpine
Each package format now runs in its native container image:
- apt: ubuntu:noble (reprepro)
- rpm: fedora:latest (createrepo_c)
- pacman: archlinux:latest (repo-add + bsdtar built-in)
- apk: alpine:latest (apk + abuild-sign built-in)
This eliminates cross-distro tool availability issues. Desktop
packages are downloaded and renamed per format to match the mage
target glob patterns. Also adds --allow-untrusted to apk index
since nfpm-produced .apk packages are unsigned.
repo-add uses bsdtar to validate packages, which requires
libarchive-tools. The .archlinux extension works fine with repo-add
so the rename to .pkg.tar.zst was unnecessary. Also removes debug
steps.
reprepro uses gpgme for signing which fails in CI environments because
gpgme cannot access pinentry. Instead, remove SignWith from the reprepro
distributions config and sign Release files manually with gpg after
reprepro finishes, producing both Release.gpg and InRelease.
reprepro uses gpgme which bypasses the preset passphrase cache and
tries to launch a pinentry dialog, failing in CI with
"Inappropriate ioctl for device". Adding loopback pinentry mode
allows gpgme to obtain the passphrase without a dialog.
New CI job runs after os-package and desktop jobs complete. Downloads
all package artifacts, runs Mage repo targets to generate repository
metadata (APT, RPM, APK, Pacman), GPG-signs the metadata, and uploads
to R2 under /repos/.
Publishes to stable suite for tagged releases, unstable for main
branch builds. Artifact uploads from os-package and desktop jobs are
no longer gated on tags to support this.
Add four new Release namespace targets:
- release:repo-apt — generates APT repo metadata using reprepro
- release:repo-rpm — generates RPM repo metadata via createrepo_c
- release:repo-apk — generates Alpine APK index via apk index + abuild-sign
- release:repo-pacman — generates Pacman database via repo-add
All targets read REPO_SUITE env var (stable/unstable, default stable)
to support publishing to different repository suites.
When a user has configured default reminders in their frontend settings,
those are cloned onto every task created via quick-add magic that has a
parsed due date. Tasks without a due date are silently skipped.
The buildDefaultRemindersForQuickAdd helper is exported as a pure
function so it can be unit-tested without stubbing the Pinia store.
Adds the settings field next to the quick add magic mode select, hidden
when that mode is set to Disabled. Uses Reminders.vue directly with
allow-absolute=false and default-relative-to pinned to due date.
Reminders.vue only read three task fields (dueDate/startDate/endDate)
and wrote one back (reminders). The ITask coupling was accidental.
Flip the prop to ITaskReminder[] and pass defaultRelativeTo / allowAbsolute
as plain props. TaskDetailView now owns the due/start/end priority
computation and binds v-model="task.reminders" directly. This also lets
the settings page reuse Reminders.vue for configuring default reminders.
Adds an allowAbsolute prop to ReminderDetail that hides the 'Date and
time' option when set to false, and a lockRelativeTo prop on
ReminderPeriod that hides the relativeTo select and forces the chosen
value. ReminderDetail threads them together so that when absolute
reminders are disallowed, the Custom form can only produce reminders
that anchor to defaultRelativeTo.
Multiple TaskFactory.create(1, {id: N, ...}, false) calls for the same
project were all defaulting to index=1 (from {increment} with count=1),
which collides on the newly added UNIQUE(project_id, index) constraint.
Mirror the numeric id override to index so each row stays unique and
matches the id == index convention used by raw seedTasks helpers.
Fixes the e2e playwright seed failures in subtask-duplicates, list/table
filter/search, kanban filter/search, and overview specs.
The four boolean OIDC provider fields (emailfallback, usernamefallback,
forceuserinfo, requireavailability) were parsed with a strict .(bool)
type assertion. That works for YAML/JSON config where leaves are native
bools, but fails for every other input path: env vars always arrive as
strings, and GetConfigValueFromFile (used by the *.file Docker secret
convention) also always returns strings. The assertion would silently
zero the field for emailfallback and usernamefallback, and log an error
and zero the field for forceuserinfo and requireavailability, which is
what #2599 reports.
Extract a small parseBoolField helper that accepts both native bools and
strings (via strconv.ParseBool) and logs a parse error from each call
site. This also fixes the previously-silent drop of stringified
emailfallback / usernamefallback values — those now log an error if the
input is garbage, matching the behaviour of the other two fields.
Fixes#2599
Regression test for #2599. Exercises getProviderFromMap with native
bools and with stringified booleans ("true"/"false"/"1"/"0") for all
four boolean provider fields — emailfallback, usernamefallback,
forceuserinfo, requireavailability. From env vars and from the
GetConfigValueFromFile path every leaf arrives as a string, so the
current .(bool) assertion silently zeros these fields.
Re-check props.enabled inside the dialogRef watcher. The watcher fires
once Vue mounts the <dialog>, but the caller may have flipped enabled
back to false between the openDialog() call and the mount flush. In that
case the prop state is disabled and we must not open the dialog.
Addresses augmentcode review on #2604.
If the modal is re-enabled within the 150ms close transition the
<dialog> element is still mounted and [open], so the dialogRef watcher
does not re-fire. Clear the leftover data-closing flag directly in
openDialog() so the dialog doesn't remain stuck at opacity 0.
Addresses augmentcode review on #2604.
Replace the nextTick-based showModal() call with a watch on the template
ref so the dialog is opened exactly when the <dialog> element mounts.
The previous implementation could silently skip showModal() if the mount
was deferred past the first nextTick, leaving the dialog in the DOM with
opacity: 0 and no click target. Observed in the Vikunja Desktop v2.3.0
Electron build where the search (quick actions) button was unresponsive.
Closes#2590
MySQL 8 rejects CAST(... AS int) (only SIGNED/UNSIGNED/CHAR/... are
accepted as target types), causing /api/v1/projects, /api/v1/tasks,
and /api/v1/labels to return HTTP 500 for every authenticated user on
MySQL 8. SQLite, Postgres, and MariaDB lax mode silently accepted the
expression, which is why the regression (introduced in e3045dfd0,
shipped in v2.3.0) passed CI — the mysql CI matrix leg uses
mariadb:12, not real MySQL 8.
Replace the two CAST(all_projects.is_archived AS int) expressions in
the recursive project CTE with MAX(CASE WHEN ... THEN 1 ELSE 0 END),
which is dialect-agnostic and needs no cast on any supported backend.
Fixes#2589
Regression test for #2589. Locks the contract that getAllProjectsForUser
exposes inherited is_archived for child projects of archived parents and
filters them out when getArchived=false, exercising both the MAX(...)
column expression and the HAVING MAX(...) = 0 filter.
2026-04-11 17:20:53 +00:00
805 changed files with 114606 additions and 11451 deletions
description: Use when adding or changing a resource on the Huma-backed /api/v2 API (new endpoints, porting a v1 resource, editing pkg/routes/api/v2/). Covers per-operation Huma handlers, the shared envelopes, error/auth bridging, REST verb conventions, and what's automatic.
user-invocable: true
---
# Adding /api/v2 routes for a CRUDable resource
`/api/v2` is served by [Huma v2](https://github.com/danielgtaylor/huma) mounted on an Echo group via the vendored `pkg/modules/humaecho5` adapter. Unlike v1's generic `WebHandler`, each operation is a typed Huma handler registered explicitly. The handlers are thin: they pull auth off the context, call the same `pkg/web/handler.Do*` functions v1 uses, and translate domain errors into RFC 9457 responses.
**Reference implementation:** `pkg/routes/api/v2/labels.go` is the canonical example — copy its shape. Shared envelopes live in `pkg/routes/api/v2/types.go`; the auth/error bridge in `pkg/routes/api/v2/errors.go`; config in `pkg/routes/api/v2/huma.go`.
## Prerequisite: the model must be CRUDable
v2 handlers call `handler.DoReadAll/DoReadOne/DoCreate/DoUpdate/DoDelete`, which invoke the model's `Can*` methods. If the model isn't already a working v1 resource, do the model work first — invoke the **`crudable`** skill. Permissions are enforced at the model level; **never** re-check them in a v2 handler.
**Every exposed model field needs a `doc:` tag.** v2's schema is reflected from struct tags at runtime; Huma cannot read the Go doc comments swaggo uses for v1. A field without `doc:"..."` ships with no description in the spec. Add the tag alongside the existing comment (keep both — swaggo still reads the comment for v1, and they should stay in sync):
```go
// The title of the label. You'll see this one on tasks associated with it.
Title string `json:"title" minLength:"1" maxLength:"250" doc:"The title of the label. You'll see this one on tasks associated with it."`
```
These model edits are safe for v1 — swaggo, XORM, and govalidator all ignore the `doc` tag. (Huma *does* read validation tags like `minLength`/`maxLength`/`enum`/`format`, so those carry over without a `doc` tag.) As with operations, a `doc` tag earns its place when it says something the field name and type don't: a format hint ("hex, 6 chars"), a read-only note ("set by the server; ignored on write"), units, or allowed values. "The label description." on a `Description` field is filler. See `pkg/models/label.go` for the reference.
**Mark server-controlled fields `readOnly:"true"`.** Because the same model struct is the request body *and* the response, fields the client can never set — `id`, `created`, `updated`, `created_by`, and similar server-derived relations/IDs — should carry `readOnly:"true"`. Huma reflects this into the OpenAPI schema (`readOnly: true`), so docs and client generators present the field as response-only and drop it from request examples:
```go
ID int64 `json:"id" readOnly:"true" doc:"The unique, numeric id of this label."`
CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who created this label."`
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this label was created. You cannot change this value."`
```
The tag is **documentation only** — Huma does *not* reject these fields if a client sends them on create/update. Actual immutability still comes from the model layer (XORM-managed `created`/`updated`, `created_by` being `xorm:"-"` and set server-side). It's also harmless on v1 (swaggo/XORM/govalidator ignore it). Don't bother tagging fields that are already `json:"-"` (absent from the schema entirely), and skip it on response-only structs like the error model — there it's cosmetic since they never appear as a request body. See `pkg/models/label.go` and `pkg/user/user.go`.
## Steps
### 1. Create `pkg/routes/api/v2/<resource>.go`
Define the list-response body, a `Register<Resource>Routes(api huma.API)` function, and one handler per operation. Mirror `labels.go` exactly:
```go
// Element type matches what models.<Model>.ReadAll returns; extra fields
// tagged json:"-" keep the wire shape identical to the plain model.
type fooListBody struct {
Body Paginated[*models.Foo]
}
func RegisterFooRoutes(api huma.API) {
tags := []string{"foos"}
Register(api, huma.Operation{
OperationID: "foos-list",
Summary: "List foos",
Description: "Returns the foos the authenticated user has access to, paginated.",
Use the package's `Register` wrapper, **not**`huma.Register` directly — it sets `DefaultStatus` from the verb (POST → 201, DELETE → 204). Don't spell out `DefaultStatus` unless you need a non-default code. Don't set `Security:` per operation — it's applied globally in `NewAPI`.
**Every operation needs a `Summary` and `Description`.** v2's OpenAPI spec is generated from these `Operation` fields at runtime — unlike v1's swaggo, Huma cannot read Go doc comments, so anything you don't put in the `Operation` (or in a `doc:` tag, see below) is simply absent from the spec and the docs UI. An operation without them ships undocumented.
**Make the description document the non-obvious — don't restate the verb+noun.** "Deletes a label" adds nothing over `DELETE /labels/{id}`. Spend the description on what a consumer *can't* infer from the method/path/schema: permission scope ("only the owner may delete it"; "returns only labels you can see, not a global list"), full-replace vs partial (PUT replaces, PATCH merges), read-only/conditional behavior (ETag → `If-None-Match` → 304), side effects (create sets ownership), non-obvious status codes. If the honest description is just the verb+noun, a short summary alone is fine — don't pad. See `labels.go` for the calibration.
### 2. Write the handlers
Every handler: pull auth with `authFromCtx(ctx)`, call the matching `handler.Do*`, wrap returned errors in `translateDomainError`. Use the shared envelopes from `types.go` (`singleBody`, `singleReadBody`, `emptyBody`, `ListParams`, `Paginated`/`NewPaginated`).
- **List** takes `*ListParams` (gives you `page`/`per_page`/`q` for free, already `doc:`-tagged in `types.go` — no need to re-document them) and returns `*fooListBody`. **You must type-assert the `DoReadAll` result to the concrete slice** — `result` is `any`, and a blind cast or a generic wrapper silently serialises `[]` (the "generic-any silent-empty trap"). Return a hard error on mismatch:
```go
items, ok := result.([]*models.Foo)
if !ok {
return nil, fmt.Errorf("foos.ReadAll returned unexpected type %T", result)
- **Extra query params go *directly* on the handler's input struct — not in a shared/embedded helper.** Beyond `ListParams`, if an operation needs its own query params (`expand`, `order_by`, `include_public`, …), declare each as a direct field with its own `query:"…"` tag on that operation's input struct, then bind it onto the model. A shared or embedded struct of query fields silently **fails to bind** under Huma when combined with other query params/embeds — the field arrives empty (hit while implementing Project's `expand`). Flatten them into the input struct.
- **Read** embeds `conditional.Params` in its input. To surface the caller's permission, define a small per-resource response struct that **embeds the model by value** and adds the permission: `type fooReadBody struct { models.Foo; MaxPermission models.Permission \`json:"max_permission" readOnly:"true" doc:"..."\` }`. Go and Huma both promote the embedded model's fields, so the wire shape is flat (model fields + `max_permission`) with no custom marshaler and nothing added to the shared model struct. Capture `DoReadOne`'s returned max permission (it is `0`/`1`/`2` on success — **never discard it as `_`**), build the body, and `return conditionalReadResponse(&in.Params, body, foo.Updated, maxPermission)`. The shared helper (in `types.go`) folds the permission into the ETag (so a share/role change invalidates the cache), applies the conditional precondition (304/412), and returns `*singleReadBody[fooReadBody]`. See `labels.go`/`project_views.go`. (A generic `struct{ T; ... }` is impossible — Go forbids embedding a type parameter — so the per-resource struct is the price of a flat shape without a marshaler.)
- **Create / Update** return `*singleBody[Model]` and set the model's `ID` from the path (URL wins over body). **Update's request body must be the same `fooReadBody` the read returns, not the bare model** — AutoPatch's GET→PUT round trip echoes the read body (max_permission included) into the PUT, and because `max_permission` is a declared `readOnly` property of `fooReadBody`'s schema, Huma accepts and ignores it on write rather than rejecting it. Take `&in.Body.Foo` (the embedded model — value-embedded, so never nil) and ignore the embedded `MaxPermission`. Create stays a bare `Body Model` (AutoPatch only round-trips into PUT).
- **Delete** returns `*emptyBody`.
### 3. Self-register the resource
Resources self-register — **you do not edit `pkg/routes/routes.go`**. In your resource file, add an `init()` that hands your registrar to `AddRouteRegistrar`:
`registerAPIRoutesV2` in `routes.go` calls `apiv2.RegisterAll(api)`, which runs every registered registrar (in init/filename order — route order is irrelevant) and then `EnableAutoPatch`. New resources touch zero shared lines, so they never conflict on `routes.go`.
Notes:
- **Give each registrar a DISTINCT name.** They share package `apiv2`, so two resources both exporting `RegisterAvatarRoutes` collide and won't compile — that actually happened and the upload one had to be renamed (`RegisterAvatarRoutes` for the binary endpoint vs `RegisterAvatarUploadRoutes` for the upload). Name yours after the specific resource.
- **Config-gated resources check the flag inside the registrar.**`RegisterAll` runs at request-router-setup time, after config is loaded, so a `RegisterFooRoutes` may early-return (or skip individual `Register` calls) based on `config.FooEnabled.GetBool()`. Don't try to gate at `init()` time — config isn't loaded yet.
- **AutoPatch is automatic.**`RegisterAll` calls `EnableAutoPatch` after all registrars — don't call it yourself, and don't register a manual PATCH (see "What's automatic").
## REST verb conventions (v2 inverts v1)
| Operation | v1 | v2 |
|---|---|---|
| create | PUT | **POST** |
| update | POST | **PUT** (and PATCH) |
| read / read-all / delete | GET / GET / DELETE | same |
## Non-CRUDable / custom routes
Not everything is plain CRUD — bulk operations, custom actions (`POST /tasks/{id}/duplicate`), sub-resource toggles, RPC-ish endpoints. These still go through Huma and reuse most of the machinery, but two responsibilities move **into your handler** because there's no `handler.Do*` doing them for you:
1. **Permission enforcement is now yours.** This is the one place the "never check permissions in the handler" rule inverts. With no generic `Do*` to call the model's `Can*`, the handler must do it explicitly — load the relevant entity and call its permission method, then refuse on denial. Mirror the v1 custom-handler shape (`pkg/routes/api/v1/task_attachment.go`):
```go
func tasksDuplicate(ctx context.Context, in *struct{ ID int64 `path:"id"` }) (*singleBody[models.Task], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
s := db.NewSession()
defer s.Close()
t := &models.Task{ID: in.ID}
can, err := t.CanUpdate(s, a) // or whichever Can* gates this action
if err != nil {
_ = s.Rollback()
return nil, translateDomainError(err)
}
if !can {
return nil, huma.Error403Forbidden("forbidden")
}
// ... do the work against s ...
if err := s.Commit(); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.Task]{Body: t}, nil
}
```
2. **Session / transaction management is now yours.** The `Do*` helpers open and commit their own `xorm.Session`; custom handlers open one with `db.NewSession()`, `defer s.Close()`, and `Commit`/`Rollback` explicitly for anything that writes.
Otherwise the same rules apply: register with the `Register` wrapper, pull auth via `authFromCtx`, route every error through `translateDomainError`, and reuse the `types.go` envelopes — or define a small body struct when none fits (don't bend a custom response into `singleBody` if it's awkward).
**Verb choice:** pick by semantics, not the CRUD table. Non-idempotent actions are `POST`. AutoPatch only synthesises PATCH for GET+PUT *pairs*, so standalone custom routes are never touched.
**Token permissions still automatic, but mind the derived name:** `collectRoutesForAPITokens` keys a route off its prefix-stripped path, so `POST /api/v2/tasks/{id}/duplicate` lands under the `tasks` group as a `duplicate` permission. Single-segment custom paths fall into the `other` group. Name the path so the derived `(group, permission)` reads sensibly — that string is what users grant tokens against.
## What's automatic — do NOT hand-roll
- **PATCH** — `EnableAutoPatch` synthesises a JSON-Merge-Patch PATCH for every GET+PUT pair. `RegisterAll` invokes it after all registrars, so it's automatic — don't call `EnableAutoPatch` and don't register PATCH yourself.
- **API token permissions** — `collectRoutesForAPITokens` walks the Echo router after registration, so your new routes land in the v2 token table automatically under the same `(group, permission)` keys as their v1 names. PATCH is intentionally not stored; `CanDoAPIRoute` accepts it as an alias for the stored PUT (see `pkg/models/api_routes.go`).
- **Security schemes** — `JWTKeyAuth` + `APITokenAuth` are declared globally in `NewAPI`. For a public endpoint, set `Security: []map[string][]string{}` on that operation and add its path to `unauthenticatedAPIPaths` in `routes.go`.
- **Error shape** — `translateDomainError` maps any `web.HTTPErrorProcessor` (e.g. `ErrFooDoesNotExist`) onto Huma's status error, producing RFC 9457 `application/problem+json`. Errors without HTTP semantics become 500.
- **OpenAPI spec / Scalar docs / `$schema` URLs** — handled in `huma.go`. Leave `Servers` alone (the relative entry must stay at index 0).
## Anti-patterns (these get flagged)
- Re-checking permissions in the handler instead of trusting `handler.Do*` → the model's `Can*`.
- Blind `result.([]*models.Foo)` without the `ok` check, or returning the `any` straight into the envelope — silent empty lists.
- `huma.Register` instead of the package `Register` wrapper (loses the verb-based status).
- Per-operation `Security:` lines (now global) or registering a manual PATCH (AutoPatch does it).
- Returning a raw model error instead of routing it through `translateDomainError` → leaks a 500 instead of the right code.
- Unquoted ETag in the response header.
- Operations without `Summary`/`Description`, or model fields without `doc:` tags — they ship undocumented because Huma can't read Go comments.
- Server-controlled fields (`id`, `created`, `updated`, `created_by`) on a shared input/output model left without `readOnly:"true"` — the docs then present them as writable request fields.
## Tests (mandatory)
Mirror the v1 webtest shape so v2 parity is readable side-by-side. Use the `webHandlerTestV2` harness in `pkg/webtests/integrations.go` — it takes the same `urlParams` map as v1's `webHandlerTest`. See `pkg/webtests/huma_label_test.go`:
- One `Test<Resource>` covering list/read/create/update/delete, positive + negative (forbidden, nonexistent), mirroring the v1 model test.
- v2-only behaviour (ETag/304, PATCH merge-patch) goes in separate top-level `Test<Resource>_*` funcs using the `humaRequest`/`humaTokenFor` helpers in `pkg/webtests/huma_helpers_test.go`.
- The RFC 9457 error-body shape is asserted **once** globally in `TestHuma_ErrorShapeIsRFC9457` — don't re-assert the full problem+json shape per resource, just the status code.
Run with `mage test:filter Test<Resource>` while iterating. **Caveat:**`mage test:filter` injects `-short`, which makes `pkg/webtests` skip entirely (the suite short-circuits in short mode), so it silently reports success without running your webtest. To actually exercise a single webtest, run it directly: `go test -run '<Name>' ./pkg/webtests/`. Save output to a file per the project test-output rule.
description: Use when adding or modifying a model in pkg/models/ that needs CRUD operations or permission checks. Covers Can* method placement, CRUDable interface, and required test coverage.
user-invocable: true
---
# CRUDable + Permissions
Models in `pkg/models/` that expose CRUD operations must implement the `CRUDable` interface **and** the permission methods. Permissions are enforced at the **model level** via `Can*` methods — never re-checked in route handlers.
**Reference docs:** read `pkg/web/readme.md` for the full interface definitions, DB session semantics, and call order. The interface lives at `pkg/web/web.go`. This skill is a checklist of what the review feedback surfaces on top of that.
## Before writing CRUD or route code
1. Decide which operations the model needs: Read / ReadAll / Create / Update / Delete.
2. Implement the matching permission methods on the model. Typical signatures:
- `CanRead(s *xorm.Session, a web.Auth) (bool, int, error)`
- `CanCreate(s *xorm.Session, a web.Auth) (bool, error)`
- `CanUpdate(s *xorm.Session, a web.Auth) (bool, error)`
- `CanDelete(s *xorm.Session, a web.Auth) (bool, error)`
3. If a handler or service needs to check access, call the `Can*` method. Do **not** re-implement the check inline or duplicate the logic in `pkg/routes/`.
4. Do not implement empty stub methods just to satisfy the interface, instead embed the interface in the struct. Check existing models to see how that's done.
Look at `pkg/models/project.go` or `pkg/models/task.go` for reference implementations.
The initial querying of the data should happen in the Can* function. Because we're operating on a pointer, the function that does the work should not need to re-query the model data.
## Anti-patterns (these get flagged every time)
- Permission logic inlined in `pkg/routes/` handlers instead of on the model.
- Shipping `Create` but forgetting `CanUpdate` / `CanDelete` because "only create is new right now".
- Re-querying the DB in the handler to decide access — that work belongs in `CanRead`.
- Copy-pasting permission logic across `CanUpdate` and `CanDelete` — extract a helper.
- Adding a handler that bypasses the generic CRUD handler in `pkg/web/handler/` without a clear reason (the generic handler already invokes the `Can*` methods for you).
## Tests (mandatory)
Every `Can*` method needs both positive and negative coverage. Run with `mage test:filter <TestName>` while iterating.
- User with direct permission → passes
- User without permission → denied
- Permission inherited via parent (e.g., project → task, team → project) → still passes
- Shared access edge cases (link shares, team membership) if the model supports them
## Related
- Generic CRUD handler: `pkg/web/handler/`
- Permission type definitions: `pkg/web/auth.go`, `pkg/models/permissions.go`
- After the model is stable, register the routes in `pkg/routes/api/v1/` and add Swagger annotations. Do not edit `pkg/swagger/` directly — it's generated.
description: Use when creating or editing files in pkg/migration/. Covers cross-DB type safety across MySQL/PostgreSQL/SQLite, DDL error handling, time-column conventions, and path sanitization.
user-invocable: true
---
# Database Migrations
Migrations are **irreversible in production**. Vikunja supports MySQL, PostgreSQL, and SQLite — every migration must work on all three.
## Before writing
1. Generate the skeleton: `mage dev:make-migration <StructName>`.
2. The migration struct must mirror the model in `pkg/models/` exactly (field names, types, xorm tags).
3. Use `time.Time` for time columns. Never use `string`, `varchar`, or `text` for times.
4. For renames or type changes, verify the conversion is safe on all three DBs:
- MySQL will silently coerce `VARCHAR` → `BIGINT` during `ALTER`. Don't rely on that — migrate data explicitly.
- SQLite has limited `ALTER TABLE`; prefer `xorm` migration helpers over raw SQL when possible.
- PostgreSQL is strict about types; explicit casts are often required.
## Error handling on DDL
Every error from `tx.Exec`, `session.Exec`, or xorm calls must be handled. Silent discards are the most commonly flagged bug in migration reviews.
```go
// WRONG — silently drops errors; migration reports success even on failure
_, _ = tx.Exec("CREATE INDEX idx_foo ON bar(baz)")
// RIGHT — error is returned so the migration rolls back cleanly
if _, err := tx.Exec("CREATE INDEX idx_foo ON bar(baz)"); err != nil {
return err
}
```
If you **must** discard a DB error (e.g., idempotent best-effort cleanup where the index might already exist), write a one-line comment explaining why. No comment = reviewer will flag it.
## Path and user input
If the migration touches user-supplied paths, filenames, or import blobs (restore, dump, import modules under `pkg/modules/migration/`), sanitize before use. Never `filepath.Join` raw input. Watch for `..` traversal in archive entry names.
## Model and frontend sync
- If the migration adds or changes a field, update the struct in `pkg/models/` with matching xorm tags.
- Update the TypeScript interface in `frontend/src/modelTypes/` to match the Go struct shape. Frontend services must match backend model structure exactly.
## Testing
- Migrations don't have dedicated unit tests, but the model's feature tests must pass against the new schema. Run `mage test:feature` (uses SQLite by default).
- If you suspect DB-specific behavior, flag it in the PR description so reviewers know to verify against MySQL/PostgreSQL.
## Related
- Existing examples: browse `pkg/migration/` for patterns; recent files are usually the cleanest references.
- Never edit `pkg/swagger/` (generated).
- Never commit `config.yml.sample` (generated by `mage generate:config-yaml`).
path:pkg/routes/api/v2/backgrounds.go# the unsplash provider intentionally uses context.Background(); its interface is shared with v1 and can't take a context
- linters:
- revive
text: 'var-naming:avoid package names that conflict with Go standard library package names'
**`/api/v1` is effectively deprecated and frozen.** It still runs and is fully supported for existing clients, but it should not grow.
- **Every new route goes on `/api/v2`** (the Huma-backed API in `pkg/routes/api/v2/`). This includes new CRUDable entities, new custom/non-CRUD endpoints, and new actions on existing resources.
- **Before adding any v2 route, invoke the `api-v2-routes` skill** — it covers both CRUD and non-CRUD shapes.
- **Touch `/api/v1` only to:** fix a bug, or port an existing resource to v2. Do not add net-new functionality there.
- Models in `pkg/models/` are shared by both APIs — a new entity still gets its model + `Can*` methods (invoke `crudable`); only the HTTP surface differs (v2, not v1).
If a task says "add an endpoint for X" without naming a version, it means v2.
## Skills
Before writing code in these areas, invoke the matching skill with the `Skill` tool. They are short checklists derived from recurring review feedback — loading them up front avoids rework.
- Adding or modifying a model in `pkg/models/` (new CRUD, new or changed `Can*` methods, anything touching permissions): invoke `crudable`.
- Creating or editing any file under `pkg/migration/`: invoke `migration`.
- Adding **any** new API route (new entity, custom action, or porting from v1) — all new routes go on the Huma-backed `/api/v2`, editing `pkg/routes/api/v2/`: invoke `api-v2-routes`. See the API Version Policy above.
## Plans and Worktrees
When the user asks you to create a plan to fix or implement something:
@ -165,11 +184,10 @@ Modern Vue 3 composition API application with TypeScript:
### Adding New Features
**Backend Changes:**
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required
2. Add database migration if needed: `mage dev:make-migration <StructName>`
1. Create/modify models in `pkg/models/` with proper CRUD and Permissions interfaces as required (invoke the `crudable` skill)
2. Add database migration if needed: `mage dev:make-migration <StructName>` (invoke the `migration` skill)
3. Create/update services in `pkg/services/` for complex business logic
4. Add API routes in `pkg/routes/api/v1/` following existing patterns
5. Update Swagger annotations
4. Add API routes on **`/api/v2`** in `pkg/routes/api/v2/` — invoke the `api-v2-routes` skill. Do **not** add new routes to `/api/v1`; it is frozen (see API Version Policy above)
**Frontend Changes:**
1. Create TypeScript interfaces in `src/modelTypes/` matching backend models
@ -185,10 +203,11 @@ Modern Vue 3 composition API application with TypeScript:
4. Update TypeScript interfaces in frontend `src/modelTypes/`
### API Development
- All API endpoints follow RESTful conventions under `/api/v1/`
- Use generic web handlers in `pkg/web/handler/` for standard CRUD operations
- Implement proper permissions checking using the Permissions interface
- Add Swagger annotations for automatic documentation generation
- **New endpoints go on `/api/v2`** (Huma-backed, `pkg/routes/api/v2/`). `/api/v1` is frozen — see the API Version Policy near the top. Invoke the `api-v2-routes` skill before writing v2 routes.
- v2 verb conventions differ from v1: POST creates, PUT/PATCH update (v1 used PUT to create, POST to update).
- Both versions reuse the generic `pkg/web/handler/``Do*` functions for standard CRUD, which enforce permissions via the model's `Can*` methods.
- Implement permission checks at the model level via the Permissions interface — never in the route handler (the exception: non-CRUD v2 actions must call `Can*` explicitly; the skill covers this).
- v2 generates its OpenAPI spec from Go types automatically — no Swagger annotations. v1's swaggo annotations stay as-is but no new ones are needed.
### Testing
- Backend: Feature tests alongside source files, web tests in `pkg/webtests/`
@ -243,6 +262,8 @@ In the frontend, all translation strings live in `frontend/src/i18n/lang`. For t
You only need to adjust the `en.json` file with the source string. The actual translation happens elsewhere.
After adjusting the source string, you need to call the respective translation library with the key. Both are similar, check the existing code to figure it out.
**Do not add a new language from scratch or translate strings into other languages yourself.** Translations are managed through a dedicated workflow. If you are asked to add a new language, translate existing strings, or update translations for non-English locales, point the user to the translation guide instead: https://vikunja.io/docs/translations/
## Key Files and Conventions
**Configuration:**
@ -254,17 +275,25 @@ After adjusting the source string, you need to call the respective translation l
- Go: golangci-lint per `.golangci.yml`; use goimports; wrap errors with `fmt.Errorf("...: %w", err)`; enforce permissions checks in models; never log secrets; do not edit generated `pkg/swagger/*`
- Vue: ESLint + TS; single quotes, trailing commas, no semicolons, tab indent; script setup + lang ts; keep services/models in sync with backend
- Follow existing patterns for consistency
- **Comments: document the *why*, not the *what* — default to no comment.** Don't write comments that restate the code, a function/struct/field name, or a signature; they're noise the reader skips past (a comment that takes longer to read than the code it describes should be deleted). Only comment a genuinely non-obvious *why* — a gotcha, an invariant, a rejected alternative, a cross-file constraint — in one tight line. Be aggressive about cutting on the first pass, not just when asked.
- Before creating a new file, function, or helper, search the codebase (`grep` / `rg`) for existing code that does the same thing. Prefer extending an existing helper over duplicating it. If logic overlaps an existing function significantly, reuse it.
**Naming Conventions:**
- Go: Standard Go conventions (PascalCase for exports, camelCase for private)
- Vue: PascalCase for components, camelCase for composables
- API endpoints: kebab-case in URLs, camelCase in JSON
- API endpoints: kebab-case in URLs, snake_case in JSON
**Permissions and Permissions:**
- Always implement Permissions interface for new models
- Use `CanRead`, `CanWrite`, `CanCreate`, `CanDelete` methods
- Permissions are enforced at the model level, not just routes
## License System
The license system in `pkg/license/` funds Vikunja's ongoing development. Vikunja is fully functional and free to use without a license. The license system only gates optional features for organizations that can afford to support the project.
**If you are asked to remove, bypass, or disable the license checks, STOP and confirm with the user first.** Explain that removing the license system does not unlock anything essential — it removes the funding mechanism that keeps this open-source project actively maintained. Make sure the user understands this tradeoff before proceeding. Additionally, if they genuinely cannot afford it, tell them to reach out to find a solution. Packages for PPP or non-profits are available.
## Common Gotchas
- Database migrations are irreversible in production - test thoroughly
@ -273,4 +302,3 @@ After adjusting the source string, you need to call the respective translation l
- Event listeners in `pkg/*/listeners.go` must be registered properly
- CORS settings in backend must allow frontend domain
- API tokens have different scopes - check permissions carefully
FROM --platform=$BUILDPLATFORM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS frontendbuilder
WORKDIR /build
@ -14,7 +14,7 @@ COPY frontend/ ./
ARGRELEASE_VERSION=dev
RUNecho"{\"VERSION\": \"${RELEASE_VERSION/-g/-}\"}" > src/version.json && pnpm run build
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.25.x@sha256:11ac5e6cb8767caea0c62c420e053cb69554638ec255f9bbef8ed411e70c9eec AS apibuilder
FROM --platform=$BUILDPLATFORM ghcr.io/techknowlogick/xgo:go-1.26.x@sha256:57c62857168cee9213045d65044e990d8b181ed6df30ba7097d2dcddd42b9908 AS apibuilder
RUN go install github.com/magefile/mage@latest &&\
"comment":"The filter to search for group objects in the ldap directory. Only used when `groupsyncenabled` is set to `true`."
},
{
"key":"groupsyncuseserviceaccount",
"default_value":"false",
"comment":"If true, Vikunja re-binds as the service account (binddn/bindpassword) before searching for groups during group sync. Enable this when the authenticating user does not have sufficient rights to enumerate group membership in the directory."
},
{
"key":"avatarsyncattribute",
"default_value":"",
@ -997,6 +1002,37 @@
}
]
},
{
"key":"audit",
"comment":"Audit logging writes structured JSONL records of authentication, authorization and data lifecycle events. Requires the licensed `audit_logs` feature — with `audit.enabled: true` but no active license, listeners are registered but nothing is written until a license with the feature becomes active.",
"children":[
{
"key":"enabled",
"default_value":"false",
"comment":"Whether to enable audit logging."
},
{
"key":"logfile",
"default_value":"",
"comment":"The file audit log entries are written to, one JSON object per line. If empty, defaults to `audit.log` in the configured log path."
},
{
"key":"rotation",
"children":[
{
"key":"maxsizemb",
"default_value":"100",
"comment":"Rotate the audit log file once it exceeds this size in megabytes. Set to 0 to disable size-based rotation."
},
{
"key":"maxage",
"default_value":"30",
"comment":"Delete rotated audit log files older than this many days. This only applies to the local rotated files, it is not a retention policy. Set to 0 to keep rotated files forever."
}
]
}
]
},
{
"key":"outgoingrequests",
"children":[
@ -1056,6 +1092,16 @@
"comment":"The plugin loader to use. \"yaegi\" loads plugins from Go source files (directories of .go files). \"native\" (deprecated) loads compiled Go plugin shared libraries (.so files)."
}
]
},
{
"key":"license",
"children":[
{
"key":"key",
"default_value":"",
"comment":"The license key for Vikunja. If empty or absent, Vikunja runs in community mode with all non-licensed features available."