Commit Graph

14110 Commits

Author SHA1 Message Date
kolaente bcade97fa4 fix(link-sharing): resolve share read permission via project id so by-id reads work
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.
2026-06-05 09:17:25 +00:00
kolaente b107685063 feat(api/v2): add link sharing (create/read/list/delete)
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.
2026-06-05 09:17:25 +00:00
kolaente 4e5751ebfe docs(api/v2): tag LinkSharing fields for the v2 schema
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.
2026-06-05 09:17:25 +00:00
kolaente cae89caef2 feat(api/v2): add bot user CRUD on /api/v2
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.
2026-06-05 08:51:39 +00:00
kolaente 9e234911f2 feat(api/v2): add API token list/create/delete on /api/v2
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.
2026-06-05 08:49:23 +00:00
kolaente c3c648f060 docs(models): tag APIToken fields for v2 OpenAPI reflection
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).
2026-06-05 08:49:23 +00:00
kolaente 413006e9ba feat(api/v2): add task labels (create/list/delete) on /api/v2
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*.
2026-06-05 08:33:47 +00:00
kolaente 171d14d7b8 feat(api/v2): add session list/delete on /api/v2
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.
2026-06-05 08:21:48 +00:00
kolaente 6bbb700f36 docs(models): add doc and readOnly tags to Session fields for v2
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.
2026-06-05 08:21:48 +00:00
kolaente a763fed573 test(api/v2): assert both ETags non-empty in team permission test 2026-06-05 08:06:54 +00:00
kolaente 58d882d36d test(api/v2): assert team max_permission + etag reflects permission 2026-06-05 08:06:54 +00:00
kolaente 184384b68c feat(api/v2): report max_permission on team reads 2026-06-05 08:06:54 +00:00
kolaente 2fc11630b4 test(api/v2): assert task comment max_permission and per-caller ETag
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.
2026-06-05 07:43:38 +00:00
kolaente 9a184fdfab feat(api/v2): report max_permission on task comment reads
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.
2026-06-05 07:43:38 +00:00
kolaente 62979ff342 test(api/v2): strengthen project max_permission assertions
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.
2026-06-05 07:40:07 +00:00
kolaente 33b9aa6292 test(api/v2): isolate project tests per-handler, not via shared harness
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.
2026-06-05 07:40:07 +00:00
kolaente bec991288b refactor(api/v2): align project max_permission to the shared embed pattern
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).
2026-06-05 07:40:07 +00:00
kolaente 25665f887f test(api/v2): port full v1 project coverage (permission matrix, archived)
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.
2026-06-05 07:40:07 +00:00
kolaente a3370a9a49 fix(api/v2): drop ETag/conditional read on project get
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.
2026-06-05 07:40:07 +00:00
kolaente 2f68a3fae4 fix(api/v2): omit project max_permission (null) when not expanded
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.
2026-06-05 07:40:07 +00:00
kolaente 0a7750ee3d feat(api/v2): add Project CRUD on /api/v2
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.
2026-06-05 07:40:07 +00:00
Frederick [Bot] fd2f005a3b chore(i18n): update translations via Crowdin 2026-06-05 00:31:43 +00:00
kolaente af2482aab2 fix(labels): report owner-level max_permission
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.
2026-06-04 21:16:51 +00:00
kolaente e22e169fb9 feat(api/v2): report max_permission on label and project-view reads
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.
2026-06-04 21:16:51 +00:00
kolaente 6836903c5f feat(api/v2): add shared conditional read helper and document list params
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).
2026-06-04 21:16:51 +00:00
kolaente 72445c4d2f docs(agents): comments should document the why, not the what
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.
2026-06-04 20:59:45 +00:00
renovate[bot] e39885682c chore(deps): update dev-dependencies 2026-06-04 18:30:29 +00:00
kolaente d604d8d443 test(api/v2): port full v1 TaskDuplicate coverage
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.
2026-06-03 20:29:15 +00:00
kolaente c9f8b87263 test(api/v2): port full v1 avatar coverage to TestAvatar
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.
2026-06-03 19:58:27 +00:00
kolaente 984a2633cc docs(task-comments): trim comments to the non-obvious why
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.
2026-06-03 19:57:26 +00:00
kolaente 88832a3e8b test(api/v2): port full v1 task comment coverage (permission matrix, IDOR, search) 2026-06-03 19:57:26 +00:00
kolaente 4d404e376a test(api/v2): prove author-only comment restriction with a writer non-author
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.
2026-06-03 19:57:26 +00:00
kolaente 808ef2534e fix(task-comments): derive update event doer from authenticated user
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.
2026-06-03 19:57:26 +00:00
kolaente 3271a1e1af feat(api/v2): add nested task comment CRUD
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).
2026-06-03 19:57:26 +00:00
kolaente 67aca34124 test(api/v2): port full v1 admin projects coverage
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.
2026-06-03 19:48:08 +00:00
kolaente 58bc03d712 test(api/v2): port full v1 project view coverage 2026-06-03 19:46:38 +00:00
kolaente 5c05a1a289 test(api/v2): port full v1 label coverage
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.
2026-06-03 19:38:57 +00:00
Frederick [Bot] 8dada8b298 [skip ci] Updated swagger docs 2026-06-03 19:23:14 +00:00
kolaente c0392e42ac test(api/v2): port full v1 team coverage (permission matrix, public discovery, exact cardinality, DB persistence) 2026-06-03 18:56:12 +00:00
kolaente cdb1db855b test(api/v2): cover include_public team surfacing and its config gate 2026-06-03 18:56:12 +00:00
kolaente dd32e3e496 fix(api/v2): keep include_public out of the team body schema
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.
2026-06-03 18:56:12 +00:00
kolaente 3233dff545 docs(api/v2): mark team external_id read-only 2026-06-03 18:56:12 +00:00
kolaente dab6ac620d feat(api/v2): add team CRUD endpoints
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.
2026-06-03 18:56:12 +00:00
kolaente 2cb0fdcded docs(skills): note v2 query params must be direct fields on the handler input
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.
2026-06-03 18:14:37 +00:00
renovate[bot] 58b2aaa74e chore(deps): update dev-dependencies to v10.9.2 2026-06-03 13:14:33 +00:00
kolaente ceb2b4f161 docs(api/v2): keep registrar godoc attached; clarify registry concurrency + ordering
- 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.
2026-06-03 13:14:13 +00:00
kolaente 5257922f3b docs(skills): document v2 self-registration pattern
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.
2026-06-03 13:14:13 +00:00
kolaente b04d4d269c refactor(api/v2): self-register resource routes via init() registry
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.
2026-06-03 13:14:13 +00:00
kolaente 220af19a39 refactor(api/v2): register upload route as RegisterAvatarUploadRoutes
Avoids a duplicate RegisterAvatarRoutes declaration in package apiv2 now that
the avatar GET route (#2818) is on main; both routes are registered distinctly.
2026-06-02 11:55:25 +00:00
kolaente b18e051ab3 fix(api/v2): reject non-decodable images (e.g. SVG) on avatar upload with 400 2026-06-02 11:55:25 +00:00