diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 00000000..91493945 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,104 @@ +# Task API (since 2.2, in progress) + +A REST API shaped like ClickUp API v2, for scripts and agents with a personal token (spec 213). **Audit status: re-verification pending.** The read endpoints are in; writes arrive before 2.2.0 is tagged, along with the OpenAPI document. + +## Read endpoints + +Paths after `api.prefix`. *Level* is the least a token needs; the person's access and where still apply. + +| Endpoint | Level | Notes | +|---|---|---| +| `GET /v2/user`, `GET /v2/team` | My tasks | A team lists no members | +| `GET /v2/team/{team_id}/space`, `GET /v2/space/{id}` | My tasks | Spaces where reaches and whose plan holds the module | +| `GET /v2/space/{id}/folder` | My tasks | Always `{"folders": []}`: every board is a folderless list | +| `GET /v2/space/{id}/list`, `GET /v2/list/{id}` | My tasks | `task_count` counts only what the token may read; columns are `statuses` (first `open`, last `closed`, the rest `custom`) | +| `GET /v2/list/{id}/task`, `GET /v2/team/{team_id}/task` | My tasks (its own) | 100 per page from `page=0`, `last_page`; `archived`, `subtasks`, `include_closed`, `statuses[]`, `assignees[]`, `tags[]`, `due_date_gt`/`_lt`, `date_created_*`, `date_updated_*`, `date_done_*`, `order_by` (`id`, `created`, `updated`, `due_date`), `reverse`, `include_markdown_description`; on a team also `space_ids[]`, `list_ids[]` | +| `GET /v2/task/{id}` | My tasks (its own) | `include_subtasks`, `include_markdown_description` | +| `GET /v2/task/{id}/comment` | My tasks (its own) | 25 per page, newest first; `start` and `start_id` of the oldest one shown give the next page | +| `GET /v2/task/{id}/member` | Read | Everyone attached to the task | +| `GET /v2/space/{id}/tag` | Read | Tags of the open tasks the token reaches | +| `GET /v2/list/{id}/field` | Read | Custom fields a provider sync imported; none otherwise | +| `GET /v2/team/{team_id}/time_entries` | My tasks | The host's time store answers; `start_date`, `end_date`, `assignee`, `task_id` | + +A malformed filter value is 400 `INPUT_002`. Unknown query parameters are ignored on reads. Each list answers in the same number of queries whatever the page holds (8 for a list's tasks, measured at 25 and 100 tasks), the tag filter included. + +**Cost per board.** A team's lists and tasks ask the host's access decision once per board the team reaches (`BOARD_ACCESS_CHECK`, in Beacon a voter), and the plan gate once per space. The bundle's own queries stay the same with 1 board or 10; the host's checks grow with the boards. + +**Time entries.** Without `task_id`, the bundle checks only the team: the host's time hook must keep to what the token's level and where reach, and to the people whose time the person may see. With `task_id`, the task goes through the guard first. + +**What a page shows of other tasks.** Subtasks, dependencies and links are shown only when the token may open the other task too. For *My tasks*, the checklist items of its own tasks are shown whoever created them, as it manages those checklists. + +## Differences from ClickUp (so far) + +- Ids are strings: the bundle's UUIDs, and the host's ids for people. +- Dates are Unix milliseconds as strings, at the precision the database keeps (seconds). +- No folders, group assignees, points, task URL or custom task id (`null` or empty). +- Checklists are subtasks tagged `checklist:`; a checklist's id encodes its task and name. +- Tags have no colours (`tag_fg`, `tag_bg` are null). +- `GET /v2/task/{id}/member` lists the task's members, not everyone who can open it. +- Dependencies stay within a board; `Related` and `Duplicates` read as `linked_tasks`. +- Task order defaults to `created` ascending; `reverse=true` flips it. + +## Off by default + +With `api.enabled: false` a host gets no route, service, listener, parameter or table. + +## What a host provides + +The bundle stores no tokens. `api.hooks.authenticator` is required with `enabled: true`; every other hook has a default, and every default denies. + +| `api.hooks.*` | Interface in `Nowo\TaskBoardBundle\Api\Hook` | Without it | +|---|---|---| +| `authenticator` | `TaskApiAuthenticatorInterface` | The container does not build | +| `rate_limiter` | `TaskApiRateLimiterInterface` | Every request 429 | +| `plan_gate` | `TaskApiPlanGateInterface` | 403 `PLAN_001` | +| `workspace` | `TaskApiWorkspaceInterface` | No teams or spaces, every board 404 | +| `users` | `TaskApiUsersInterface` | Nobody is known | +| `time_entries` | `TaskApiTimeEntriesInterface` (list and log; tracked time is never deleted through the API) | 403 `TIME_001` | +| `attachments` | `TaskApiAttachmentsInterface` | 403 `FILE_001` | +| `idempotency_store` | `TaskApiIdempotencyStoreInterface` | The bundle's store, when writes arrive | + +A listener on `TaskBoardEvents::API_WRITE` (`TaskApiWriteEvent`) audits each write. + +## Firewall + +The API must sit behind a **stateless firewall** whose security user is the token's user, as the access checkers read the security token. `security: false` answers 500 `SERVER_001` on every request. For a bad token the firewall lets the request through with no user, and adds no redirecting `access_control` on the path: the bundle answers, so all refusals share one shape. + +```yaml +nowo_task_board: + api: + enabled: true + prefix: /api/tasks + host_decides_access: true # only with security.resource_scoping: false, when your listeners decide access + hooks: + authenticator: App\Api\TaskApiAuthenticator +``` + +The build refuses `security.allow_unauthenticated: true`, and `security.resource_scoping: false` without `host_decides_access: true`: both would open every board to every token. + +## Access + +Each request: level ∩ the person's access ∩ where. In this order, and the first failure answers: + +| Check | Answer | +|---|---| +| Out of reach: missing, outside where, no space for its tenant, no view access, for My tasks not its own | 404 `ACCESS_001`, always the same | +| The tasks module is not in the plan | 403 `PLAN_001` | +| The level may not | 403 `TOKEN_001` | +| The person may not | 403 `ACCESS_002` | + +Levels are not a chain: *Read* changes nothing, while *My tasks* works on its own tasks. See `TaskApiCapability`. + +**Where.** `TaskApiWhere::limitedTo($tenantKeys, $boardIds)`: each entry grants on its own. A named tenant reaches all its boards; a named board is reached even when its tenant is not listed. Nothing else widens: the person's access, the workspace hook and the tenant check apply to it too. + +**Tenancy.** With `tenancy.enabled`, a board is reached only when the bundle's tenant resolver opens its tenant for the person (the slug being the key), as the UI asks of the tenant in its URL, whatever the workspace hook answers. A board without a tenant is not served then, as in the UI. + +**Notes for hosts.** The security-user check compares ids, not classes: a firewall user of another class with the same id would pass, so use one user class for the API's firewall. An `access_control` refusal on the API's path answers 403 `ACCESS_002`, not 401: add none there. A token whose user the access checker refuses answers 401 like a bad token and counts as a failed token. + +## Errors + +`{"err": "...", "ECODE": "..."}`, with `OAUTH_017` (401, no header), `OAUTH_019` (401, token not valid, one answer for every reason), `API_001` (404, the instance switch), `RATE_001` and `RATE_002` (429), and `SERVER_001` (500, no detail). Answers carry `X-RateLimit-Limit`, `-Remaining`, `-Reset` (Unix seconds) and `Cache-Control: no-store, private`. The credential is read only from the `Authorization` header, with or without `Bearer`, and never logged. + +## Known differences from the UI + +Task text is returned raw. A description or comment that embeds a host file (in Beacon, a `beacon-file:` image) shows an API reader the image's alt text and its UUID, which the UI hides from a viewer who may not open the file. The file's bytes stay behind the host's download check. Wiki and document references are not resolved in task text, so they show nothing more. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5ba2d088..06568da7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -86,6 +86,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +Target: 2.2.0, spec 213 (Task API). Audit status: re-verification pending. Opt-in: with `api.enabled: false` (the default) no route, service, listener, parameter or table changes, and the UI's output stays as it was. See [API](API.md). + +### Added + +- **Task API, read endpoints.** Spaces, folders (none), lists, tasks with ClickUp's filters and 100 per page, a task, comments, members, tags, imported custom fields and time entries (through the host's time hook). Lists hold and count only what the token may read; each list answers in a fixed number of queries. See [API](API.md). +- **Task API, skeleton (`api.*`).** `GET /v2/user`, `GET /v2/team` under `api.prefix`; the request pipeline (failed-token limit, authenticator, security user, rate limit), `Api\Security\TaskApiGuard` (level ∩ access ∩ where), `Api\TaskApiCapability`, one `{err, ECODE}` shape. Hooks in `Api\Hook`, where every default denies; event `TaskBoardEvents::API_WRITE`. The build refuses `security.allow_unauthenticated` and, without `api.host_decides_access`, `security.resource_scoping: false`. +- `Service\TaskManager::restore()` and `archive()`, with their history; `restore()` asks the host's limit and throws `TaskReopenRefusedException` when it refuses. +- `Service\TaskBoardEraser::eraseTask()`: one task and its subtasks, leaf-first through Doctrine, tracked time kept and detached. Needs the board's manage decision; a task that follows a provider, or has a subtask that does, is refused with `TaskLinkedToProviderException`. The files a sync stored for the subtree, kept after an unlink, are named in each `TaskErasedEvent` and their rows removed, so the host can delete them (audit IA-M1). +- `Service\TaskBoardEditor::update()`: name and description of a board, the slug unchanged, `TaskBoardEvents::BOARD_UPDATED` (`BoardUpdatedEvent`). +- `Service\TaskCommentPolicy`: who may write, edit and delete a comment. +- `Validation\TaskConstraints`: the rules of the fields a write takes. +- `Service\TaskDependencyManager::findBetween()`, `Dto\TaskFormData::fromTask()`. + +### Changed + +- `TaskBoardManageController` asks `TaskCommentPolicy` and `TaskManager::restore()`, and the write forms take their rules from `TaskConstraints`. Their output does not change: `tests/Functional/Characterization` pins it. Its `$syncLimits` argument stays in place but is deprecated and ignored: `TaskManager` holds the same limit (audit IA-I1). + ## [2.1.1] - 2026-11-07 Target: 2.1.1. Reported by Beacon on 2.0.2. No schema, configuration or permission change. Audit HZ (2026-11-07): Pass (good) / Low. See [UPGRADING](UPGRADING.md#to-211). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2d3ff911..39da4755 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -6,6 +6,7 @@ - [Database](#database) - [Routes](#routes) - [Why a route loader instead of `#[Route]` attributes (REQ-SF-004)](#why-a-route-loader-instead-of-route-attributes-req-sf-004) +- [Task API (since 2.2)](#task-api-since-22) - [Security](#security) - [Firewall](#firewall) - [Access checker](#access-checker) @@ -143,6 +144,10 @@ from this configuration, not declared with `#[Route]` attributes on `TaskBoardMa Route names, paths, methods and the prefix are unchanged by 1.21. +## Task API (since 2.2) + +Off by default. `api.enabled`, `api.prefix`, `api.host_decides_access`, `api.quota_listener_required`, `api.max_body_bytes` and `api.hooks.*`: see [API](API.md). + ## Security ### Firewall diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 5346359b..4f615c84 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -12,6 +12,7 @@ - [Task types, tag colours and descriptions (since 1.28.0)](#task-types-tag-colours-and-descriptions-since-1280) - [Task numbers and calendar (since 2.1)](#task-numbers-and-calendar-since-21) - [UiKit migration (2.0.0)](#uikit-migration-200) +- [Shared write rules (2.2, re-verification pending)](#shared-write-rules-22-re-verification-pending) - [Logging and personal data](#logging-and-personal-data) - [Personal data in synced tasks (RGPD / LOPDGDD)](#personal-data-in-synced-tasks-rgpd--lopdgdd) - [Dependencies](#dependencies) @@ -27,6 +28,7 @@ | **Task forms** | Symfony forms for board/task creation and updates (title, description, links, members). | | **Configuration** | `nowo_task_board` YAML (routes, table prefix, access checker, team resolver). | | **Provider sync (since 1.23, opt-in)** | Server-side calls to a task provider's API with the host's token, file downloads from the provider's attachment hosts, remote text stored as Markdown. | +| **Task API (since 2.2, opt-in)** | Stateless routes under `api.prefix`, off by default. Token authentication is the host's; `Api\Security\TaskApiRequestListener` and `TaskApiGuard` fail closed. Audit IA: a token whose user the access checker refuses counts as a failed token (L1); with tenancy the guard asks the bundle's tenant resolver too (L2); the security-user check compares ids, not classes, and an `access_control` refusal reads as 403 (I2, I3, documented in [API](API.md)). `TaskManager::archive()` dispatches no domain event, as no UI archive exists; the API's write audit is `API_WRITE` (I5). Audit status: re-verification pending. | | **TimeTrack bridge** | Optional. When TimeTrack is installed, task provider exposes trackable tasks; access gated by `TaskAccessGuard`. | ## Threats and mitigations @@ -164,6 +166,10 @@ Both opt-in. - **Avatars.** Pictures now pass UiKit's `AvatarUrlPolicy`: https without userinfo, or a same-origin path. - **Subtask nodes.** Titles reach `tree` as text, escaped by the partial, and in `data-*` attributes through `nowo_ui_safe_attrs`. +## Shared write rules (2.2, re-verification pending) + +Spec 213 gives the comment rules, the restore limit and the field constraints one home (`TaskCommentPolicy`, `TaskManager::restore()`, `TaskConstraints`), so a write path other than the forms cannot apply different ones. `TaskBoardEraser::eraseTask()` asks the board's manage decision itself, erases leaf-first so the `PreRemove` that keeps time runs for every subtask (`parent_id` is `ON DELETE CASCADE`, which would skip it), and refuses a task that follows a provider. After an unlink, the files a sync stored for the subtree are named in `TaskErasedEvent::$attachmentRefs` and their rows removed, on every database (audit IA-M1): the host deletes them, as for `deleteBoard()`. `tests/Functional/Characterization` pins the UI's output. + ## Logging and personal data REQ-OBS-001 (since 1.21): every successful board / task write logs one `info` line, an invalid CSRF token one `warning`, diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 4c469b9c..4706d55d 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -155,6 +155,10 @@ Hosting FrankenPHP with **kernel not reset** between requests: see [FRANKENPHP-W ## Unreleased +Target: 2.2.0, spec 213. Audit status: re-verification pending. No schema or configuration change is needed, and the UI's output stays as it was. The Task API is off unless you set `api.enabled: true`: see [API](API.md). + +`TaskBoardManageController`'s `$syncLimits` argument is deprecated and ignored, kept in its place so positional arguments do not move: a restore asks the limit given to `TaskManager` (`$limits`), which the container wires from the same `sync.limit_checker`. A host that builds either by hand passes the limit to `TaskManager`. + ## To 2.1.1 From **2.1.0**: diff --git a/docs/USAGE.md b/docs/USAGE.md index 84153184..2226431b 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -30,6 +30,7 @@ - [No inline styles](#no-inline-styles) - [Look and feel: ClickUp-like views, theming and icons (since 1.12)](#look-and-feel-clickup-like-views-theming-and-icons-since-112) - [Theming with design tokens](#theming-with-design-tokens) +- [Task API (since 2.2)](#task-api-since-22) - [Host hooks (since 2.0.0)](#host-hooks-since-200) - [Icons](#icons) - [Inline new task](#inline-new-task) @@ -76,6 +77,10 @@ Authenticated users open the manage UI (default `/tools/task-board`): | Log time | `task_time` | Since 1.15: minutes, day and note (POST + CSRF) | | Remove time entry | `task_time_remove` | Since 1.15 (POST + CSRF) | +### Deleting and renaming outside the UI (since 2.2) + +The UI has no delete task or rename board. A host calling the services can: `TaskBoardEraser::eraseTask($task, $actor)` deletes a task and its subtasks and keeps tracked time, detached (a task that follows a provider is refused); `TaskBoardEditor::update($board, $name, $description, $actor)` keeps the slug and dispatches `BOARD_UPDATED`. Check access first with `TaskBoardResourceAccessChecker`, and validate with `Validation\TaskConstraints`, the rules the forms use. + ## TimeTrack integration (optional) When TimeTrack is installed and configured with `nowo_task_board.task_provider`: @@ -232,6 +237,10 @@ Overrides and demos must not reintroduce raw `
` / `` / `