docs(prd): PRD draft 0.2 with requirement ids; lead decision 48
PRDY round 2 answers, and Researcher's Vikunja and Pocket ID report as a record. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
# Vikunja and Pocket ID for slice 1: research report
|
||||
|
||||
Date: 2026-10-04. Seat: Researcher. Assignment: Sage, lead decisions 42 and 45.
|
||||
Scope: research only. Public docs and public source. No containers started, nothing deployed, no secrets or token files read.
|
||||
|
||||
## How to read the citations
|
||||
|
||||
Source cites name a repository at a fixed tag and a file path. Line numbers are given where I recorded them. Doc cites give the URL.
|
||||
|
||||
| Short | Source | Pin |
|
||||
|---|---|---|
|
||||
| V | `code.vikunja.io/vikunja` (mirror `github.com/go-vikunja/vikunja`) | tag v2.7.0, commit `a16be96aa454671fdf213b0fbe411dd38a098418`, 2026-10-02 |
|
||||
| P | `github.com/pocket-id/pocket-id` | tag v2.17.0, commit `1abc0186fcbf0a31b51d90adb24f9b4c9cea1be6`, released 2026-10-01 |
|
||||
| G | `github.com/go-gitea/gitea` | tag v28.0.0, commit `15b8a5805adf57c5189602008d38cccfd3c795e0`, released 2026-09-29 |
|
||||
| VD | `https://vikunja.io/docs/<slug>` | fetched 2026-10-04 |
|
||||
| PD | `https://pocket-id.org/docs/<path>` | fetched 2026-10-04 |
|
||||
|
||||
Two doc caveats. Every Pocket ID docs page I fetched carries the banner "This documentation is for an unreleased version of Pocket ID". It tracks the main branch. Where I rely on a doc claim for a feature, I checked that v2.17.0 source has the feature too. Second, Vikunja docs and v2.7.0 source disagree on webhook retries (section 1.3). I report both.
|
||||
|
||||
"unverified" means I did not confirm the claim in source or docs.
|
||||
|
||||
---
|
||||
|
||||
# Part 1. Vikunja as the task tracker agents manage through the API
|
||||
|
||||
## 1.1 Versions and API surfaces
|
||||
|
||||
- Current release: v2.7.0, 2026-10-02. Earlier: v2.6.0 2026-08-31, v2.5.0 2026-08-04, v2.4.0 2026-07-19, v2.3.0 2026-04-09, v2.2.2 2026-03-23, v2.2.0 2026-03-20, v2.1.0 2026-02-27 (GitHub releases API). About one minor release a month since July.
|
||||
- Two HTTP APIs ship in the same binary (V `pkg/routes/routes.go`).
|
||||
- `/api/v1`: frozen, Swagger-based. PUT creates, POST updates.
|
||||
- `/api/v2`: Huma v2, OpenAPI 3.1, generated at runtime at `/api/v2/openapi.json`, `/api/v2/openapi.yaml` and `/api/v2/docs`. POST creates, PUT or PATCH updates. List responses use `{items,total,page,per_page,total_pages}`. Errors follow RFC 9457, with 422 for validation. ETag and If-None-Match work on single GETs. Search param is `q`.
|
||||
- Docs position (VD `api-v2`, `versions`): v2 shipped in 2.4.0 and new clients should build against it. v1 is deprecated at 3.0 (the docs estimate Q3 or Q4 2026, not a promise) and removed at 4.0. Treat v1 as a migration risk.
|
||||
- Vikunja also ships an MCP module mounted at `/api/v2/mcp` (token scope `mcp.access`, V `pkg/modules/mcp`, `pkg/models/api_routes.go:46`) and an agent CLI called `veans` (VD `veans`). I did not evaluate either. The slice-1 direction says to integrate through the API, so I leave both out.
|
||||
|
||||
## 1.2 API coverage
|
||||
|
||||
Verified from the v1 route table (V `pkg/routes/routes.go`) and the v2 route files (V `pkg/routes/api/v2/`). v2 has matching files for the items marked v2.
|
||||
|
||||
| Capability | Covered | Notes |
|
||||
|---|---|---|
|
||||
| Projects: CRUD, duplicate, archive via update | yes | v2 |
|
||||
| Project views and kanban buckets | yes | `/projects/:p/views/:v/buckets`, v2 `project_views`, `buckets` |
|
||||
| Move a task between buckets | yes | `POST /projects/:p/views/:v/buckets/:b/tasks` (`pkg/models/kanban_task_bucket.go:255` dispatches `TaskUpdatedEvent`) |
|
||||
| Tasks: CRUD, bulk update, duplicate, position, read status | yes | `bucket_id`, `position`, `index` and the project-scoped identifier (for example `PROJ-12`) are task fields |
|
||||
| Assignees (single and bulk) | yes | v2 `task_assignees`. Bot users can be assignees (VD `bot-users`) |
|
||||
| Labels (single and bulk on a task) | yes | A bot can edit only labels it created (VD `bot-users`) |
|
||||
| Due, start, end dates, reminders, repeat, priority, percent done, hex colour | yes | task fields, `pkg/models/tasks.go` |
|
||||
| Relations | yes | kinds: subtask, parenttask, related, duplicateof, duplicates, blocking, blocked, precedes, follows, copiedfrom, copiedto (`pkg/models/task_relation.go`) |
|
||||
| Comments: CRUD | yes | v2 `task_comments` |
|
||||
| Attachments: upload, download, list, delete | yes | v2 `task_attachments`. Max size `files.maxsize`, default 20MB (`pkg/config/config.go:483`) |
|
||||
| Teams and members (admin flag) | yes | v2 `teams` |
|
||||
| Sharing: project to user, to team, link shares | yes | v2 `project_users`, `project_teams`. `service.enablelinksharing` default true |
|
||||
| Saved filters, subscriptions, notifications, reactions | yes | |
|
||||
| Webhooks per project | yes | `/projects/:project/webhooks`, token group `projects_webhooks` |
|
||||
| Time entries | licensed | v2 `time_entries.go`, feature `time_tracking` (section 1.6) |
|
||||
|
||||
Token reach: the token-scopable groups include `projects_views`, `projects_buckets`, `tasks_attachments`, `tasks_assignees`, `tasks_labels`, `tasks_comments`, `tasks_relations`, `teams_members`, `projects_webhooks`, `projects_views_tasks` (V `pkg/models/api_routes.go:190-216`). So everything an agent needs for tasks can be done with a scoped token.
|
||||
|
||||
### What an agent cannot do through the API
|
||||
|
||||
Verified unless marked.
|
||||
|
||||
1. Create or manage bots, create or revoke API tokens. The route groups `tokens`, `user_*` (this includes `user/bots` by the prefix rule), `subscriptions`, `oauth_authorize`, `mcp` and `token_test` are excluded from token scopes (V `pkg/models/api_routes.go:259-267`). Only a logged-in human session (JWT) reaches them. A token cannot mint another token.
|
||||
2. Read or change user settings, user-level webhooks (`/user/settings/webhooks`), or subscriptions. Same exclusion.
|
||||
3. Instance administration (list or create or disable users, admin overview, invite links). Gated by the `admin_panel` licence feature and instance-admin status (`pkg/routes/routes.go:450-457`, `pkg/license`). Without a licence there is no admin API (section 1.6).
|
||||
4. Make a bot create another bot (VD `bot-users`).
|
||||
5. Create users. No unlicensed API route does this. Options are the CLI `vikunja user create`, `/register` while `service.enableregistration` is true (default), or OIDC first login (section 1.5).
|
||||
6. Get a webhook for `project.created`, team events, label events (section 1.3).
|
||||
7. Use CalDAV or feed basic auth as a bot (VD `bot-users`).
|
||||
8. Unverified: whether a token with `expand` params can read everything a JWT user can. I confirmed expand scopes are enforced on task routes, not on every route.
|
||||
|
||||
## 1.3 Webhooks, events and sync
|
||||
|
||||
### Webhooks
|
||||
|
||||
- Per-project webhooks: `/projects/:project/webhooks` (v1 CRUD, group `projects_webhooks`). Enabled by default (`webhooks.enabled`).
|
||||
- User-level webhooks exist at `/user/settings/webhooks`, only for user-directed events, and are not reachable with a token.
|
||||
- Events registered for webhooks (V `pkg/models/listeners.go:64-79`, plus user-directed ones registered elsewhere):
|
||||
`task.created`, `task.updated`, `task.deleted`, `task.assignee.created`, `task.assignee.deleted`, `task.comment.created`, `task.comment.edited`, `task.comment.deleted`, `task.attachment.created`, `task.attachment.deleted`, `task.relation.created`, `task.relation.deleted`, `project.updated`, `project.deleted`, `project.shared.user`, `project.shared.team`; user-directed: `task.reminder.fired`, `task.overdue`, `tasks.overdue`.
|
||||
- Not registered: `project.created` (dispatched at `pkg/models/project.go:1066` but not exposed), team events, label add or remove on a task. A bucket move fires `task.updated` (`kanban_task_bucket.go:255`). A label change on a task fires no event I could find (`label_task.go` dispatches none).
|
||||
- Payload: `{event_name, time, data:{task, doer, ...}}`. If a secret is set, the header `X-Vikunja-Signature` carries HMAC-SHA256 of the raw body. Optional Basic auth. User-Agent `Vikunja/<version>`. (V `pkg/models/webhooks.go`, VD `webhooks`.)
|
||||
- SSRF guard: loopback, private and link-local targets are refused by default. Allow them with `outgoingrequests.allownonroutableips` (`pkg/config/config.go:238`). `webhooks.allownonroutableips` is deprecated and maps to it (`config.go:546-548`). A Mosaic receiver on localhost or a private network needs that flag.
|
||||
- Delivery guarantee: **the docs and source disagree.** VD `webhooks` says "Webhooks are delivered once. Failed deliveries are not retried." V v2.7.0 source (`pkg/events/events.go`, `WebhookDeliveryListener`) sets Retry with MaxRetries 5, exponential backoff and a poison queue. The event bus is an in-process watermill GoChannel and I found no persistence, so a Vikunja restart probably drops in-flight deliveries (inference, unverified). Timeout is `webhooks.timeoutseconds`, default 30. Design the receiver to be idempotent and keep a poll fallback.
|
||||
|
||||
### Sync by polling (the reliable path)
|
||||
|
||||
- `GET /tasks` and `GET /projects/:id/tasks` take `filter`, `sort_by`, `order_by`, `filter_include_nulls`, `expand` and `page` / `per_page` (V `pkg/models/task_collection.go`, `task_collection_sort.go`).
|
||||
- Filter example: `updated > 2026-10-04T00:00:00Z`, sorted by `updated`. Filterable fields: id, title, description, done, done_at, due_date, created_by_id, project_id, repeat_after, priority, start_date, end_date, hex_color, percent_done, uid, created, updated, position, bucket_id, index, plus assignees, labels, reminders, created_by. Comparators: `=`, `>`, `>=`, `<`, `<=`, `!=`, `like`, `in`, `not in`.
|
||||
- `expand` accepts subtasks, buckets, reactions, comments, comment_count, time_entries_count, is_unread.
|
||||
- Page size is capped by `service.maxitemsperpage` (default 50), so loop on `page`.
|
||||
- Soft-deleted tasks are kept 30 days with `deleted_at`. A poll sees deletes only if it reads deleted rows, and I did not confirm that an API call returns them. Treat deletes as unverified for polling; `task.deleted` webhooks cover it when delivered.
|
||||
- Single-object GETs support ETag and `If-None-Match`.
|
||||
- VD `filters` documents the filter syntax. I did not read that page. The filter behaviour above comes from source.
|
||||
|
||||
## 1.4 API tokens and bot users
|
||||
|
||||
### Tokens
|
||||
|
||||
- Format `tk_` plus 40 hex characters. Stored as SHA-256 of the token. Cleartext is returned once (V `pkg/models/api_tokens.go:73`, v2 `api_tokens.go` description: "returned once in this response and is never readable again").
|
||||
- Fields: `title`, `permissions` (map of route group to a list of permission names, validated by `PermissionsAreValid`, `api_routes.go:590`), `expires_at` (required, `api_tokens.go:60`), `owner_id`.
|
||||
- Permission names per group: `read_all`, `read_one`, `create`, `update`, `delete`, plus `_bulk` variants where they exist. `/api/v1/routes` lists the real set. In v2, PATCH is accepted as an alias of `update` (`api_routes.go:276-281`).
|
||||
- Expiry: required; I found no maximum in source. Expired tokens fail (`api_tokens.go:356`). Tokens of disabled or locked owners are inert.
|
||||
- Created in the UI **and** through the API (`POST /api/v2/tokens`), but only with a human JWT (section 1.2, item 1). An admin cannot mint tokens for arbitrary users. v2 lets a caller mint for `owner_id` set to a **bot the caller owns** (v2 `api_tokens.go:49`). `GET /tokens?owner_id=` lists a bot's tokens and `DELETE /tokens/{id}` revokes them (`api_tokens.go:40`).
|
||||
- One service identity per role works: one bot per role, one token (or several) per bot.
|
||||
|
||||
### Bot users (VD `bot-users`, V `pkg/routes/api/v2/bot_users.go`, available from 2.4.0)
|
||||
|
||||
- Username must start with `bot-` (`pkg/user/user_create.go:151`). Normal users cannot register that prefix (`user_create.go:222`).
|
||||
- No password, no email, no interactive sign-in.
|
||||
- Owned by a human user. Deleting the owner deletes the bots. Bots cannot own bots. Link shares cannot create bots.
|
||||
- Can be shared into projects, assigned to tasks, mentioned. A project a bot creates is owned by the bot's owner, and the bot gets admin on it.
|
||||
- Owner and bots share labels. A bot edits only labels it created.
|
||||
- v2 endpoints: `/user/bots` (list, create) and `/user/bots/{bot}` (read, update, delete). The exact create body is unverified; read `/api/v2/docs` on the target version.
|
||||
- No licence is needed. I found no `RequireFeature` on these routes.
|
||||
|
||||
## 1.5 Authentication
|
||||
|
||||
- Local auth: `POST /login` (v1 and v2) with username and password, plus TOTP if enabled. Returns a short-lived JWT (`service.jwtttlshort`, default 600 s) and a refresh cookie (V `pkg/config/config.go:378`). `auth.local.enabled` (default true) switches local login.
|
||||
- LDAP and OIDC are available.
|
||||
- **OIDC in Vikunja** (VD `openid`, V `pkg/modules/auth/openid/openid.go`, `providers.go`):
|
||||
- Authorization code flow, confidential client (id plus secret). No PKCE setting on Vikunja's side that I found; unverified.
|
||||
- Config: `auth.openid.enabled`, then per provider `auth.openid.providers.<key>.{name, authurl, clientid, clientsecret, scope, logouturl, forceuserinfo, emailfallback, usernamefallback, requireavailability}`. Environment form `VIKUNJA_AUTH_OPENID_PROVIDERS_<ID>_<FIELD>`. Default scope `openid profile email`.
|
||||
- Callback URL is `https://<vikunja>/auth/openid/<provider-key>` (VD `openid`). The key is matched in lower case in the Pocket ID recipe.
|
||||
- Discovery from `<authurl>/.well-known/openid-configuration`.
|
||||
- Username comes from `preferred_username`, then userinfo, then `nickname`, else random.
|
||||
- **Team sync** uses a custom claim `vikunja_groups`: a list of `{name, oidcID, description?, isPublic?}` (`openid.go:107`). `oidcID` is a string under 250 characters. Teams are created and synced at login and marked "(OIDC)".
|
||||
- Account linking: `emailfallback` and `usernamefallback` must both be true to link an OIDC login to an existing local account. Bots are rejected on that path. VD and the Pocket ID recipe both warn that this lets the provider act as a local user whenever it controls those claims.
|
||||
- **No SCIM and no bulk import** (VD `openid`). A person must log in once before being assignable.
|
||||
- Vikunja is also its own OAuth2 server (VD `oauth-server`, 2.3+): authorization code with mandatory PKCE S256, no client registration, `vikunja-` custom-scheme redirects only. It is for sign-in of first-party clients such as `veans`. It is not a service-account mechanism, and the OAuth authorize endpoint rejects API tokens (V `CHANGELOG.md`, GHSA-v3p6-34mc-hj7v).
|
||||
|
||||
## 1.6 Licence gating: what a free instance lacks
|
||||
|
||||
- `pkg/license` features: `admin_panel`, `time_tracking`, `audit_logs`, `user_invites` (V `pkg/license/license.go`).
|
||||
- `/api/v1|v2/admin/*` is behind `RequireFeature(admin_panel)` plus `RequireInstanceAdmin` (V `pkg/routes/routes.go:450-457`). The CLI `vikunja user set-admin` refuses on a free instance (V `pkg/cmd/user.go`).
|
||||
- The licence key is `license.key` / `VIKUNJA_LICENSE_KEY` (VD `pro`). With no key the instance makes no outbound licence calls. With a key it contacts `console.vikunja.io` and `check.vikunja.io` at startup and daily and sends the key, instance id, version, DB type, user counts, host OS and a container flag. A 72 h cache applies. It falls back to community mode silently. A user cap applies if the licence includes one.
|
||||
- Consequence for Mosaic: the minting flow in Part 3 needs none of this. Do not plan around instance admin.
|
||||
|
||||
## 1.7 Deployment
|
||||
|
||||
- Image: Docker Hub `vikunja/vikunja`. Tags `latest`, `2`, `2.7`, `2.7.0` (pushed 2026-10-02T17:47Z), `unstable`. Architectures amd64, arm64, arm. Pin the full version tag.
|
||||
- The image is one static binary on `scratch`. Runs as UID 1000, port 3456. `VIKUNJA_SERVICE_ROOTPATH=/app/vikunja/`, `VIKUNJA_DATABASE_PATH=/db/vikunja.db` (V `Dockerfile`). Mount `/app/vikunja/files` and `/db` and chown them to 1000. Label `org.opencontainers.image.licenses='AGPLv3'`.
|
||||
- DB types: sqlite (default), postgres, mysql (VD `full-docker-example`).
|
||||
- Key config (env prefix `VIKUNJA_`, upper-cased with underscores; full list in V `config-raw.json`, VD `config-options`):
|
||||
- `service.publicurl`, `service.interface` (`:3456`), `service.secret` (JWT signing, generated if empty, set it so tokens survive restarts), `service.timezone`
|
||||
- `service.enableregistration` (default true), `service.enablelinksharing` (default true), `service.enablecaldav`
|
||||
- `service.jwtttlshort` (default 600), `service.maxitemsperpage` (default 50)
|
||||
- `database.type|host|user|password|database|path|sslmode|schema`
|
||||
- `files.basepath`, `files.maxsize` (20MB)
|
||||
- `webhooks.enabled`, `webhooks.timeoutseconds`, `outgoingrequests.allownonroutableips`
|
||||
- `ratelimit.enabled` (default false), `ratelimit.kind` (user), `ratelimit.limit` (100), `ratelimit.period` (60)
|
||||
- `auth.local.enabled`, `auth.openid.*`, `cors.enable`, `audit.enabled` (licensed feature), `license.key`
|
||||
- The CLI is in the image: `vikunja user create|list|set-admin|change-status|reset-password|delete`, `doctor`, `dump`, `restore`, `migrate`, `repair` (VD `cli`).
|
||||
- Backup guidance: VD `what-to-backup` (database plus files directory).
|
||||
|
||||
### What the installer needs
|
||||
|
||||
**Path A, existing instance**
|
||||
1. Base URL.
|
||||
2. Version at least 2.4.0 (bots and v2). Check with `GET /api/v1/info`; I did not verify that endpoint's output shape.
|
||||
3. A human owner account that Mosaic controls, with username and password (and TOTP secret if enabled). Local login must be enabled, or the account must be able to produce a JWT some other way. An OIDC-only instance (`auth.local.enabled=false`) has no documented way to get that JWT headlessly. That would block minting. Unverified.
|
||||
4. `service.publicurl` correct, so links and webhooks work.
|
||||
5. If the Mosaic receiver is on a private address, `outgoingrequests.allownonroutableips=true` on their instance. That is an operator change on a system Mosaic does not own, so surface it as a prerequisite.
|
||||
6. Confirm the instance admin does not mind bot users and long-lived tokens.
|
||||
|
||||
**Path B, bundled instance**
|
||||
1. Compose service from the pinned image with a Postgres or SQLite volume, `/app/vikunja/files` volume, UID 1000 ownership.
|
||||
2. Secrets via runtime-only means: `VIKUNJA_SERVICE_SECRET`, DB password. Nothing in the repository or image.
|
||||
3. `VIKUNJA_SERVICE_PUBLICURL`, and `VIKUNJA_OUTGOINGREQUESTS_ALLOWNONROUTABLEIPS=true` if the receiver is on the compose network.
|
||||
4. Create the owner once with `vikunja user create -u <name> -e <email> -p <pass>` inside the container, then set `VIKUNJA_SERVICE_ENABLEREGISTRATION=false`. The CLI creation path needs no HTTP admin.
|
||||
5. Keep `auth.local.enabled` true until the tokens are minted. If Pocket ID is wired later, keep one local owner account.
|
||||
|
||||
## 1.8 Licence
|
||||
|
||||
- Root `LICENSE` is the GNU AGPL v3. Source headers say "version 3 or later". The image label says AGPLv3 (V `LICENSE`, `Dockerfile`).
|
||||
- AGPL §13 attaches a source-offer duty to **modified** versions that users interact with over a network. Mosaic would call the API of an unmodified upstream image. My reading is that this is neither modification nor linking, so §13 would not be triggered by Mosaic. That is a reading of the text, not legal advice. Treat the legal consequence as unverified and ask counsel if the stack ever patches Vikunja, redistributes a modified image, or embeds Vikunja code (decision 43 says it will not).
|
||||
- Shipping the unmodified upstream image still means distributing AGPL software. Keep the image reference upstream and unmodified, and keep the licence notice available.
|
||||
|
||||
---
|
||||
|
||||
# Part 2. Pocket ID as SSO for people
|
||||
|
||||
Version: v2.17.0 (2026-10-01). Licence: BSD 2-Clause (P `LICENSE`). Stack: Go backend, Gin, fosite OAuth library. Storage: SQLite by default, PostgreSQL via `DB_CONNECTION_STRING` (PD `configuration/environment-variables`). Single process; v2 refuses to run two instances on one database (PD `setup/major-releases/migrate-v2`).
|
||||
|
||||
## 2.1 Release and maturity
|
||||
|
||||
- Tags: v2.17.0 2026-10-01, v2.16.0 2026-09-20, v2.15.0 2026-09-19, v2.14.0 2026-08-18, v2.13.0 2026-08-07, v2.12.0 2026-07-29, v2.11.0 2026-07-13, v2.10.0 2026-07-10, v2.9.0 2026-06-16, v2.8.0 2026-05-31, v2.7.0 2026-05-11, v2.6.2 2026-04-21 (GitHub releases API). Twelve releases in about five months. Minor releases come every one to four weeks.
|
||||
- Within a major version upgrades need no config change; the database migrates itself at startup; downgrade is refused unless `ALLOW_DOWNGRADE=true` (PD `setup/upgrading`).
|
||||
- Images: `ghcr.io/pocket-id/pocket-id` and `pocketid/pocket-id`. Port 1411. Needs `APP_URL` (HTTPS for passkeys, except localhost) and `ENCRYPTION_KEY` (at least 16 characters, mandatory since v2.0). Release feature set is moving fast. Pin the tag.
|
||||
- I did not assess project governance or the number of maintainers. Unverified.
|
||||
|
||||
## 2.2 OIDC features
|
||||
|
||||
Verified in P and in the discovery document built at `backend/internal/controller/well_known_controller.go:98-127`.
|
||||
|
||||
| Item | Result |
|
||||
|---|---|
|
||||
| Flows | authorization code, refresh token, device authorization, client credentials (confidential clients only) |
|
||||
| Response types | `code` only. No implicit flow |
|
||||
| PKCE | `plain` and `S256` advertised. Per-client `pkceEnabled`. When on, the authorize request must carry a `code_challenge` (`oidc/authorization_service.go:750-757`). Public clients are supported (`isPublic`) |
|
||||
| Pushed authorization requests | supported, per-client requirement flag |
|
||||
| Token endpoint auth | `client_secret_basic`, `client_secret_post`, `none`; federated JWT client assertion (PD `guides/oidc-client-authentication`) |
|
||||
| Scopes | `openid`, `profile`, `email`, `groups`, `offline_access`, plus permissions of APIs you define |
|
||||
| Claims | `sub`, `given_name`, `family_name`, `name`, `display_name`, `email`, `email_verified`, `preferred_username`, `picture`, `groups`, `auth_time`, `amr` |
|
||||
| Tokens | ID token 1 h, access token JWT (RFC 9068) 60 min configurable per client, refresh token 30 days of inactivity configurable per client, auth code 15 min (PD `guides/scopes-and-claims`). RS256 by default |
|
||||
| Revocation on refresh | at each refresh, it checks that the user exists, is not disabled and is still in an allowed group (PD `guides/scopes-and-claims`, `oidc/claims_service.go:37-62`) |
|
||||
| Logout | RP-initiated end-session plus back-channel logout |
|
||||
| Per-client access control | allowed user groups (`isGroupRestricted`, `PUT /api/oidc/clients/:id/allowed-user-groups`) |
|
||||
| Client ID Metadata Documents | supported when an allowlist is configured |
|
||||
| Callback URL wildcards | strict rules since v2.0 |
|
||||
|
||||
### Client registration
|
||||
|
||||
- Admin UI and REST: `POST /api/oidc/clients`, `PUT /api/oidc/clients/:id`, `DELETE`, secrets at `/api/oidc/clients/:id/secrets` (create, list, delete), allowed groups at `/allowed-user-groups` (P `controller/oidc_controller.go:30-54`). The client id can be supplied on create (`min=2,max=128`). A client secret can be supplied by the caller (`min=16`) or generated, with an optional expiry (`dto/oidc_dto.go`). The secret is shown once.
|
||||
- No dynamic client registration endpoint is advertised in discovery.
|
||||
|
||||
### Groups and custom claims
|
||||
|
||||
- `groups` claim: array of group **names** (not display names) when the `groups` scope is requested (`claims_service.go`, PD `guides/scopes-and-claims`).
|
||||
- Custom claims are set on a user or on a group (`PUT /api/custom-claims/user/:userId` and `/user-group/:userGroupId`). They are released with the `profile` scope, in the ID token and userinfo, **not** in the access token. Values that parse as JSON are emitted as JSON. Reserved names (`groups`, `email`, `sub`, `name`, and so on) cannot be used (`service/custom_claim_service.go:22-45`).
|
||||
- Collision rule: a user's own claim wins over a group claim. If several groups set the same claim, which wins is undefined in the docs, and in source the first one found in the database order is kept (`custom_claim_service.go:247-289`). **Do not spread one claim name over several groups.**
|
||||
- Fit with Vikunja team sync: Pocket ID's `groups` claim is a list of strings, but Vikunja wants `vikunja_groups` as objects with `name` and `oidcID`. The way to bridge it is a custom claim named `vikunja_groups` with a JSON value such as `[{"name":"mosaic","oidcID":"mosaic"}]`, set per user (or on one group). It needs the `profile` scope on the Vikunja provider, which is already in Vikunja's default scope. That is untested; I did not run it.
|
||||
|
||||
### User and group provisioning
|
||||
|
||||
- Users: `GET|POST /api/users`, `PUT /api/users/:id`, `PUT /api/users/:id/user-groups`, `DELETE`. Create body: username, email, names, `isAdmin`, `disabled`, `userGroupIds` (P `dto/user_dto.go`).
|
||||
- Groups: `GET|POST /api/user-groups`, `PUT /:id`, `PUT /:id/users`, `PUT /:id/allowed-oidc-clients`.
|
||||
- First sign-in is a passkey. An admin can create a login code through `POST /api/users/{id}/one-time-access-token` and the user adds a passkey with it (PD `setup/user-management`, `guides/sign-in-methods`). Signup tokens exist for self-service signup (`/api/signup-tokens`).
|
||||
- LDAP sync of users and groups is built in (`LDAP_*`, PD `configuration/ldap`).
|
||||
- **SCIM is outbound only.** Pocket ID acts as a SCIM client and pushes users and groups to an app's SCIM endpoint (PD `configuration/scim`, P `scimsync/`). It does not expose a SCIM server. Vikunja states it has no SCIM (VD `openid`) and I found no SCIM in Gitea source (grep for "scim", case-insensitive, over G v28.0.0 returned nothing). So SCIM does not help with either target.
|
||||
|
||||
## 2.3 Machine and non-passkey credentials
|
||||
|
||||
Stated plainly: people sign in to Pocket ID only with passkeys, login codes, "sign in with another device" and (optionally) a login code by email. There is no password login (PD `guides/sign-in-methods`).
|
||||
|
||||
For machines, Pocket ID does offer:
|
||||
|
||||
1. **Client credentials grant.** Confidential clients only. The client needs an "API" defined in Pocket ID (an audience URL plus named permissions) and a client-access (M2M) grant for the permissions it may request. The token request carries `resource=<api url>` and `scope=<permission>`. The access token has no identity scopes, subject `client-<client_id>`, lifetime per client, no refresh token (P `oidc/token_handler.go:66-110`, `oidc/client.go:50-60`, PD `guides/apis`). This is the OIDC-standard service identity.
|
||||
2. **Federated client credentials**: the client authenticates with a JWT from another issuer (Kubernetes, Azure, GitLab, a tailnet) instead of a secret (PD `guides/oidc-client-authentication`).
|
||||
3. **Device authorization grant** (user-in-the-loop on another device).
|
||||
4. **Admin API keys** (section 2.4). These are for Pocket ID's own REST API, not for other apps.
|
||||
|
||||
What this does **not** give you: a way for an agent to obtain a Vikunja or Gitea credential. A Pocket ID client-credentials token is a JWT with `aud` equal to an API you define. Vikunja's documented use of OIDC is user login via authorization code (VD `openid`). Gitea's OIDC is likewise an authentication source for web login (PD `client-examples/gitea`). I found no documented setting in either that accepts an external bearer token as an API credential. I did not read the Vikunja or Gitea auth middleware to rule it out, so treat this as unverified. Plan on per-role tokens issued by each service, as the assignment says.
|
||||
|
||||
## 2.4 Admin API and API keys
|
||||
|
||||
- "Everything the admin UI does goes through the REST API" (PD `api`). Endpoints list: PD `api/endpoints`. Header `X-API-Key`. List endpoints take `pagination[page]` and `pagination[limit]`. Errors have a stable `code` and a `request_id`.
|
||||
- Automatable through it: users, user groups, group membership, OIDC clients and their secrets and allowed groups, custom claims, APIs and their permissions and client grants (`/api/apis...`), SCIM providers, signup tokens, one-time access tokens, application configuration, audit log, LDAP sync trigger.
|
||||
- API key properties (P `apikey/`, `middleware/api_key_auth.go`):
|
||||
- A key belongs to the user who created it and acts with that user's rights. The middleware requires `user.IsAdmin` on admin routes. The model has no scopes field, so keys are all-or-nothing. A non-admin's key reaches only the non-admin routes.
|
||||
- Required `expiresAt` in the future. Name 3 to 50 characters. Token is 32 random alphanumeric characters, stored as SHA-256, returned once. An expired key can be renewed (a new token is issued). The server emails the owner before expiry when configured.
|
||||
- Creating or renewing a key needs a normal logged-in session. It rejects an API key (`apikey/module.go:70-71`, `authWithoutApiKey`). A key cannot mint keys.
|
||||
- `STATIC_API_KEY` (env, at least 16 characters, supports `_FILE`): a fixed admin key that acts as a synthetic "Static API User" with `IsAdmin` true. No expiry, no scope (P `common/env_config.go:70,222`, `apikey/service.go:initStaticApiKeyUser`). Docs: "Prefer regular API keys where you can."
|
||||
- Headless bootstrap: the first admin normally registers at `/setup` and adds a passkey in a browser. The setup count ignores the static user (`usersignup/service.go:220-228`), so with `STATIC_API_KEY` set an installer can create a first real admin with `POST /api/users` and then mint a login code for the passkey step. The passkey still needs a human at a browser. A recovery CLI exists: `pocket-id one-time-access-token <user>` (PD `troubleshooting/account-recovery`).
|
||||
|
||||
## 2.5 Official integration recipes
|
||||
|
||||
- **Vikunja** (PD `client-examples/vikunja`): create a client with callback `https://<vikunja>/auth/openid/pocketid`, restrict by allowed groups, then set `auth.openid.providers.PocketID.{name, authurl=<pocket id base url>, clientid, clientsecret, scope="openid profile email", forceuserinfo}` or the env form `VIKUNJA_AUTH_OPENID_PROVIDERS_POCKETID_*`. Optional `usernamefallback` and `emailfallback` link existing local accounts, with a security warning about it. Note: the recipe also shows `auth.openid.redirecturl`. I did not find that key in V v2.7.0 `pkg/config/config.go`, where the redirect URL arrives from the client request (`openid.go:52,625`). Treat that line as stale (unverified which). Vikunja's own docs `openid-example-configurations` mention no Pocket ID (grep returned 0).
|
||||
- **Gitea** (PD `client-examples/gitea`): callback `https://<gitea>/user/oauth2/PocketID/callback`. In Gitea, Site Administration, Authentication Sources, OAuth2, provider "OpenID Connect", name `PocketID`, auto-discovery URL from Pocket ID, "Skip local 2FA" on, additional scopes `openid email profile`. The source name must match the callback path.
|
||||
- Gitea group mapping flags exist in source: `--group-claim-name`, `--admin-group`, `--restricted-group`, `--group-team-map`, `--required-claim-name` (G `cmd/admin_auth_oauth.go:99-129`). I did not find an official Pocket ID recipe that uses them; that is untested.
|
||||
|
||||
---
|
||||
|
||||
# Part 3. Recommendation
|
||||
|
||||
## 3.1 How a role's credential is minted and handed out at launch
|
||||
|
||||
Per-role identities, minted once by a gated step, then supplied to the role at launch as runtime-only secrets (matches AGENTS.md invariant 3, decision 44, and the direction doc: "Provided at launch first. Minting stays gated").
|
||||
|
||||
**Vikunja** (no admin or licence needed):
|
||||
1. A Mosaic-controlled human owner account exists (bundled: created by CLI. Existing: the operator provides it).
|
||||
2. With the owner's JWT (`POST /api/v2/login`), for each role `R`:
|
||||
a. `POST /api/v2/user/bots` with username `bot-<R>`.
|
||||
b. Share the projects the role may touch to that bot, through the project-users route. Choose read or write. (v2 path unverified, see `/api/v2/docs`. v1 is `PUT /projects/:p/users`.)
|
||||
c. `POST /api/v2/tokens` with `owner_id=<bot id>`, a `permissions` map limited to the groups the role needs (for example `tasks`, `tasks_comments`, `tasks_assignees`, `tasks_labels`, `tasks_relations`, `tasks_attachments`, `projects`, `projects_views`, `projects_buckets` with the specific verbs), and an `expires_at`.
|
||||
3. The cleartext `tk_...` token is returned once. Write it straight to the role's secret store (read-only mount or environment variable at launch). Do not log it.
|
||||
4. The owner credential is used only in this step. Keep it out of the role launch path.
|
||||
5. Rotation: mint a new token for the same bot, switch the role over, then `DELETE /tokens/{id}`. Expiry is mandatory, so rotation is forced.
|
||||
6. Revocation: `DELETE /tokens/{id}` as the owner, or delete the bot.
|
||||
|
||||
**Gitea** (public mechanics; Mosaic's Gitea setup and tokens not read):
|
||||
1. A Gitea user per role (admin creates it: `POST /api/v1/admin/users` with a token holding the admin scope, or the CLI `gitea admin user create`). Access to repos is granted through collaborator or team membership, not through the token.
|
||||
2. Mint the token. Two routes:
|
||||
- Server-side CLI: `gitea admin user generate-access-token --username <R> --token-name <n> --scopes <list> --raw` (G `cmd/admin_user_generate_access_token.go`). Needs shell access to the Gitea host or container, and no HTTP credential.
|
||||
- HTTP: `POST /api/v1/users/{username}/tokens` with `{name, scopes}`. The route requires **basic auth**, either the user's own password or an admin's password (`reqSelfOrAdmin()` plus `reqBasicOrRevProxyAuth()`, G `routers/api/v1/api.go:1145-1149`). A bearer token is rejected on that route. Unverified: how this behaves when the account has 2FA enabled.
|
||||
3. Gitea tokens have **no expiry field** (G `models/auth/access_token.go:20-34`). Rotation is manual: create, switch, delete (`DELETE /users/{username}/tokens/{id}`). A token cannot mint a token of broader scope (`CanCreateChildScope`, `routers/api/v1/user/app.go:131-140`).
|
||||
4. Use the narrowest scopes (for example `write:repository`, `write:issue`, `read:user`; add `public-only` only for public repos). The scope list is in G `models/auth/` token-scope code and the swagger example.
|
||||
5. Admin `sudo` (`Sudo` header or `?sudo=`) lets an admin token act as another user. Do not hand that to agents.
|
||||
|
||||
**Pocket ID's role in slice 1: none for agents.** It is for people only. Design now, wire later (decision 44):
|
||||
- One Pocket ID client per app (Vikunja, Gitea), restricted to a Mosaic user group.
|
||||
- Keep Vikunja `auth.local.enabled` true and keep the local owner account, since bot minting needs a password login and that account.
|
||||
- Provision `vikunja_groups` as a per-user custom claim if team sync is wanted.
|
||||
- Pocket ID client-credentials can later authenticate **Mosaic's own services** to each other. Define an API in Pocket ID and issue M2M grants. That is a separate decision from tracker credentials.
|
||||
|
||||
## 3.2 What needs an admin token (gated actions for Jason)
|
||||
|
||||
| Action | Credential | Why it is gated |
|
||||
|---|---|---|
|
||||
| Vikunja: create the owner account (bundled) | shell access to the container, CLI | creates the root of trust for all bots |
|
||||
| Vikunja: create bots, share projects, mint tokens | the owner's JWT (password login) | the owner password is the high-value secret. Everything below it is scoped and expiring |
|
||||
| Vikunja: change `outgoingrequests.allownonroutableips`, disable registration (existing instance) | instance operator | changes someone else's server settings |
|
||||
| Gitea: create a role user | admin API token (`write:admin`) or CLI | creates identities |
|
||||
| Gitea: mint a role token | CLI on the host, or an admin's or user's password | no expiry and a basic-auth credential |
|
||||
| Pocket ID: create an admin API key | admin passkey session (UI only; a key cannot create keys) | |
|
||||
| Pocket ID: `STATIC_API_KEY` | an environment secret on the server | full admin, no expiry. If used for install, remove it afterwards |
|
||||
| Pocket ID: create or change clients, groups, users | admin API key | changes who can sign in to what |
|
||||
|
||||
I do not recommend keeping any of these admin credentials in the agent launch path.
|
||||
|
||||
## 3.3 Open risks
|
||||
|
||||
1. **Owner password is the single high-value secret on the Vikunja side.** With it one can mint tokens for every bot, and a bot's tokens cannot be minted any other way. Store it outside agent reach, use it only in the minting step. If the owner is deleted, every bot and its tokens are deleted.
|
||||
2. **No Vikunja instance admin without a paid licence.** Bulk user management and an admin overview are unavailable. The plan avoids it, but an existing instance's admin may have other expectations. Licence use also sends telemetry to Vikunja's servers (section 1.6).
|
||||
3. **v1 deprecation.** If the adapter is written against `/api/v1`, it breaks at 4.0 (deprecation at 3.0, date not committed). Build on v2.
|
||||
4. **Webhook delivery is uncertain.** Docs say no retries, v2.7.0 source retries, and the bus is in-memory. Use webhooks as a hint, polling with `updated > <last seen>` as the truth.
|
||||
5. **SSRF default blocks private receivers.** A localhost or compose-network receiver needs `outgoingrequests.allownonroutableips=true`. On a user-supplied instance, that is a prerequisite the operator may refuse.
|
||||
6. **Event gaps.** No webhook for `project.created`, label changes, or team changes. Poll those.
|
||||
7. **Tokens cannot do identity management.** Any flow that needs a new bot (a new role) needs the owner session again. Plan for it.
|
||||
8. **Gitea tokens never expire** and token creation needs a password or host shell. Make rotation a scheduled, recorded step.
|
||||
9. **Pocket ID passkey-only login.** The first admin needs a human and a browser. Headless install can create users but not their passkeys. Bundled install must pause for a person. Recovery is a CLI one-time link.
|
||||
10. **Pocket ID release pace.** Roughly one release every two weeks in 2.x, with the docs site ahead of releases. Pin `v2.17.0`, read the changelog on every bump.
|
||||
11. **SCIM does not apply.** Pocket ID's SCIM is outbound only and neither Vikunja nor Gitea documents a SCIM server. A Pocket ID user must log in to Vikunja once before they can be assigned.
|
||||
12. **Group claim shape mismatch.** Pocket ID `groups` is a string list. Vikunja team sync needs `vikunja_groups` objects. The bridge is a custom claim, per user (collisions across groups are undefined). Untested.
|
||||
13. **AGPL.** My reading is that an unmodified image plus API calls does not trigger §13. Not legal advice (section 1.8).
|
||||
14. **Docs and source drift** shows up in two places already (Vikunja webhook retries, Pocket ID recipe `redirecturl`). Pin versions and re-verify at each bump.
|
||||
|
||||
## 3.4 Unverified list
|
||||
|
||||
- Exact v2 request bodies for `POST /user/bots` and for sharing a project with a user.
|
||||
- Whether any maximum token lifetime exists in Vikunja (none found in `api_tokens.go`).
|
||||
- Whether an `expires_at` in the past is rejected at creation.
|
||||
- Whether polling returns soft-deleted tasks.
|
||||
- Whether Vikunja has a PKCE option for its OIDC client.
|
||||
- Whether Vikunja or Gitea accept any external bearer JWT as an API credential (none documented; middleware not read).
|
||||
- Whether OIDC-only Vikunja instances can produce an owner JWT headlessly.
|
||||
- Gitea basic-auth token creation on a 2FA account.
|
||||
- Legal consequence of AGPL for the Mosaic stack.
|
||||
- Pocket ID project governance and maintainer count.
|
||||
- `/docs/filters` and `/docs/api-documentation` on vikunja.io were not read. Filter behaviour above comes from source.
|
||||
- Whether a Pocket ID custom claim `vikunja_groups` works end to end in Vikunja team sync (not run).
|
||||
|
||||
## Scratch material
|
||||
|
||||
Cloned sources and fetched doc text live in `/tmp/rsrch/` (outside the repository): `vikunja` (v2.7.0), `pocket-id` (v2.17.0), `gitea` (v28.0.0), and `*.txt` doc extracts. Nothing in this repository changed except this file. Nothing was committed or pushed, no containers were started, no messages were sent, and `docs/SESSIONS.md` was not touched because the assignment limits writes to `agents/researcher/work/`.
|
||||
Reference in New Issue
Block a user