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 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.
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.
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.
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.
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.
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*.
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.
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.
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.
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).
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.
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).
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.
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.
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.
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.