Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f7ea333
test(ui): characterize comment, restore and form validation output (s…
HecFranco Nov 7, 2026
551dcc7
refactor(tasks): give comment, restore and field rules one home (spec…
HecFranco Nov 7, 2026
b0abe6b
feat(api): add the Task API skeleton behind api.enabled (spec 213 S2)
HecFranco Nov 7, 2026
073d72a
test(api): fit the coverage report in memory (spec 213 S2)
HecFranco Nov 7, 2026
e375dd9
feat(api): add the Task API's read endpoints (spec 213 S3)
HecFranco Nov 7, 2026
e6d8ab7
Merge remote-tracking branch 'origin/main' into refactor/213-s1-share…
HecFranco Nov 7, 2026
9681dd2
fix(tasks): name stored files when erasing a task; keep $syncLimits (…
HecFranco Nov 7, 2026
a793504
Merge branch 'refactor/213-s1-shared-moves' into feat/213-s2-api-skel…
HecFranco Nov 7, 2026
cb9474b
fix(api): throttle refused users, check the tenant in the guard (audi…
HecFranco Nov 7, 2026
2c44408
Merge the audit IA fixes of S1 and S2 into the read API
HecFranco Nov 7, 2026
46228c4
fix(api): hide what My tasks may not open, filter tags in SQL (audit IA)
HecFranco Nov 7, 2026
0cdd259
feat(api): add the Task API's write endpoints and idempotency (spec 2…
HecFranco Nov 7, 2026
b2ac9f2
fix(api): mention people from the comment text, as the UI's box does
HecFranco Nov 8, 2026
f16916d
feat(api): add files and time to the Task API (spec 213 S5)
HecFranco Nov 8, 2026
8ddee3a
fix(api): idempotency query, limits before the first save, tag rules …
HecFranco Nov 8, 2026
f5b1e1f
Merge the audit IA round 2 fixes of S4 into files and time
HecFranco Nov 8, 2026
ab99cc0
fix(api): replay checks for uploads and time, warnings for upload fields
HecFranco Nov 8, 2026
9ed507d
feat(api): add board and column management to the Task API (spec 213 S6)
HecFranco Nov 8, 2026
96d9b2a
test(api): compare with ClickUp's published response examples (spec 2…
HecFranco Nov 8, 2026
7d1cfd5
docs(api): note the narrow race between a restore and a move (audit IA)
HecFranco Nov 8, 2026
a0de283
Merge branch 'feat/213-s4-write-api' into feat/213-s5-files-time
HecFranco Nov 8, 2026
c5c8937
fix(api): an upload's name and type are part of its idempotency key (…
HecFranco Nov 8, 2026
d9fbda4
Merge branch 'feat/213-s5-files-time' into feat/213-s6-management
HecFranco Nov 8, 2026
167e193
docs(api): board deletes may fire their events twice when concurrent …
HecFranco Nov 8, 2026
f6f5935
Merge branch 'feat/213-s6-management' into test/213-s7-contract
HecFranco Nov 8, 2026
3254f84
test(api): pin every difference from ClickUp's published examples (sp…
HecFranco Nov 8, 2026
5c2775d
docs(api): OpenAPI, API guide, upgrade steps and security for the Tas…
HecFranco Nov 8, 2026
eca76ab
fix(api): prepend follows the last config set; everything() warning (…
HecFranco Nov 8, 2026
637494e
fix(api): narrow IA-L8 to the API; keep 2.1 gates for sync and task_n…
HecFranco Nov 8, 2026
d9548ee
docs: record audit IA verdict at 637494e
HecFranco Nov 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 294 additions & 0 deletions docs/API.md

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

Target: 2.2.0, spec 213 (Task API). Audit IA (2026-11-08): Pass-Low at `637494e`. 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, contract and docs.** `ApiClickUpContractTest` compares every endpoint's answer with the shape of ClickUp's published response examples (`tests/Fixtures/clickup/shapes.json`: names and types, not live recordings) and pins 40 documented differences. `docs/openapi.yaml` (OpenAPI 3.1), kept in step with the routes by `ApiOpenApiTest`. `docs/API.md` rewritten: turning it on, hooks, access, endpoints, idempotency, errors, differences from ClickUp, material for a host's manual.
- **Task API, management (Full).** Create, rename and delete boards (deleting erases the tasks with time kept, in slices answered 202; a provider-linked board is 409 `LIST_002`), and columns as statuses on four paths of this API's own (`/v2/list/{id}/status…`). Needs the board's manage decision, or the create check and the board plan limit.
- **Task API, files and time.** `POST /v2/task/{id}/attachment` through the host's `TaskApiAttachmentsInterface`; `POST /v2/team/{id}/time_entries` through `TaskApiTimeEntriesInterface`, for the token's own person; `DELETE` of a time entry always 403 `TIME_002`. `Api\Hook\ManualTaskApiTimeEntries` as a time store over the bundle's manual entries.
- **Task API, write endpoints.** Tasks (create, update, delete with time kept), comments, tags, dependencies, links and checklists, through the UI's services. Read writes nothing; My tasks works only on its own tasks and their checklists; custom fields answer 409 `FIELD_001`; a task that follows a provider is not deleted (409 `TASK_002`). Writes need an `API_WRITE` listener (503 `AUDIT_001`) and, for creates, a `QUOTA_CHECK` listener (403 `PLAN_002`). `Idempotency-Key` on every write, 24 hours per token, in `task_board_api_idempotency` or the host's store; `nowo:task-board:api:purge-idempotency`.
- **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()`.

### Fixed

- `api.enabled: true` in one config file and `false` in a later one (a `when@prod` override) no longer maps the idempotency table or registers `NOWO_TB_JSON_TEXT`: `prepend()` follows the last value set, as the merged configuration does, and so does `api.hooks.idempotency_store` (audit IA-L8).

### Deprecated

- `sync` and `task_numbers` still map their tables when any config file enables them, even if a later override disables them (2.1 behaviour, kept so a layered config never drops tables holding data). Deprecated: 3.0 follows the merged value.

### 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).
Expand Down
5 changes: 5 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
- [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)
- [Task API (2.2)](#task-api-22)
- [Shared write rules (2.2)](#shared-write-rules-22)
- [Logging and personal data](#logging-and-personal-data)
- [Personal data in synced tasks (RGPD / LOPDGDD)](#personal-data-in-synced-tasks-rgpd--lopdgdd)
- [Dependencies](#dependencies)
Expand All @@ -27,6 +29,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). Writes (2.2, S4): the guard's capability table per write, `API_WRITE` required, the plan limit refused when no `QUOTA_CHECK` listener answers, idempotency keys scoped to the token with hashes only and a replay checked again; the kept answers hold task text for 24 hours (`forget()` on revocation or erasure). Audit IA (2026-11-08): Pass-Low at `637494e`. |
| **TimeTrack bridge** | Optional. When TimeTrack is installed, task provider exposes trackable tasks; access gated by `TaskAccessGuard`. |

## Threats and mitigations
Expand Down Expand Up @@ -164,6 +167,29 @@ 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`.

## Task API (2.2)

Off unless `api.enabled: true`. Audit IA (2026-11-08): Pass-Low at `637494e`: independent audit IA (2026-11-07 and 08) covered the skeleton, reads, writes, files, time, management, the contract test and the docs; every finding was fixed or documented.

| Threat | Mitigation |
|---|---|
| A host widening a token by mistake | `TaskApiWhere::everything()` grants the person's whole reach: a host maps only a token's explicit "everything I can reach" to it, never an empty, missing or unreadable scope (`limitedTo([], [])`). Where only narrows; access comes from the checkers |
| A token reaching more than its person | Level ∩ the person's access ∩ where, per request, in `TaskApiGuard`; the access checkers judge the security user, which must be the token's user (else 500). Out of reach is always the same 404 |
| A token reaching another tenant | With tenancy, the bundle's tenant resolver is asked for each board's tenant, whatever the workspace hook answers |
| Brute-forcing tokens | Failed tokens per IP (`registerFailedToken`), including a valid token whose user is refused; the token's own rate limit |
| A credential in logs | Read from `Authorization` only; never logged or echoed; errors log the exception class only |
| Writes skipping the UI's rules | The UI's services and the forms' constraints (`TaskConstraints`); the plan limit refused when no `QUOTA_CHECK` listener answers; an unaudited write refused (503) |
| A retry writing twice, or a replay leaking | Idempotency keys per token, hashed; the request (method, path, query, body, upload bytes, name and type) fingerprinted; refusals not kept; a replay re-checks the token and the reach |
| Tracked time lost | Deleting tasks and boards keeps time, detached; deleting time is never offered (403) |
| Provider data | Provider connections are never reachable; linked tasks and boards are not deleted; stored files of an erased task are named to the host |
| Raw task text | Returned as stored: an API reader sees the alt text and id of an embedded host image the UI hides; the file stays behind the host's download check |

Known and documented: a multi-field update is not one transaction (refusals are checked first); a narrow race between a restore and a move can answer 500 after the restore; concurrent board deletes may dispatch `TASK_ERASED` and `BOARD_DELETED` twice; `forget()` clears one token's kept answers.

## Shared write rules (2.2)

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`,
Expand Down
32 changes: 30 additions & 2 deletions docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

- [To 1.5.1](#to-151)
- [From 1.5.5 to 1.5.6](#from-155-to-156)
- [Unreleased](#unreleased)
- [To 2.2.0](#to-220)
- [Turning the Task API on](#turning-the-task-api-on)
- [To 2.1.1](#to-211)
- [To 2.1.0](#to-210)
- [Schema change for every host: `task_board_tasks.number`](#schema-change-for-every-host-task_board_tasksnumber)
Expand Down Expand Up @@ -153,7 +154,34 @@ php bin/console cache:clear

Hosting FrankenPHP with **kernel not reset** between requests: see [FRANKENPHP-WORKER-AUDIT.md](FRANKENPHP-WORKER-AUDIT.md) and the host checklist in [DEMO-FRANKENPHP.md](DEMO-FRANKENPHP.md) (clear the Doctrine identity map between requests, or keep `services_resetter`).

## Unreleased
## To 2.2.0

Not released yet. Audit IA (2026-11-08): Pass-Low at `637494e`.

```bash
composer update nowo-tech/task-board-bundle
```

**A host that changes nothing** gets no new route, service, listener, parameter or table: the Task API is off (`api.enabled: false`) and the UI's output stays as it was (`tests/Functional/Characterization` pins it).

**API config overrides.** `api.enabled: true` in a base file and `false` in a later override no longer maps the API idempotency table or registers `NOWO_TB_JSON_TEXT` (audit IA-L8); `api.hooks.idempotency_store` also follows the last value set. `sync` and `task_numbers` keep the 2.1 behaviour: they still map their tables when any config file enables them. That is deprecated and changes in 3.0, so a host that layers them off should plan its migration before then.

**Deprecated.** `TaskBoardManageController`'s `$syncLimits` argument is 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`.

**New services a host may call:** `TaskBoardEraser::eraseTask()` (one task and its subtasks, time kept; it names the files a sync stored in `TaskErasedEvent`), `TaskBoardEditor`, `TaskManager::restore()` / `archive()` / `activeTaskRefusal()`, `TaskCommentPolicy`, `Validation\TaskConstraints`.

### Turning the Task API on

1. **Schema** (with `api.enabled: true` and no `api.hooks.idempotency_store` only): the table `task_board_api_idempotency` (`scope`, `key_hash` as primary key; `fingerprint`, `status`, `body`, `tenant_key`, `board_id`, `created_at`, `expires_at`, indexed). Generate it with `doctrine:migrations:diff`. It holds answers for 24 hours, so it needs no data migration.
2. **Hooks** under `api.hooks`: `authenticator` (required), `rate_limiter`, `plan_gate`, `workspace`, `users`, `time_entries`, `attachments`, optionally `idempotency_store`. Every default denies. Interfaces in `Nowo\TaskBoardBundle\Api\Hook`; see [API](API.md#what-a-host-provides).
3. **Firewall:** a stateless firewall on `api.prefix` whose security user is the token's user; no redirecting `access_control` there. Without it every request answers 500.
4. **Listeners:** `TaskBoardEvents::API_WRITE` (audit; without one, writes answer 503 `AUDIT_001`) and `TaskBoardEvents::QUOTA_CHECK` for task and board creates (without one, 403 `PLAN_002`, unless `api.quota_listener_required: false`).
5. **Access:** with `security.resource_scoping: false`, set `api.host_decides_access: true` once your `BOARD_ACCESS_CHECK` / `TASK_ACCESS_CHECK` listeners deny what a person may not reach; the build refuses otherwise. `security.allow_unauthenticated: true` is refused.
6. **Tenancy:** the guard asks your tenant resolver for each board's tenant by its key; a resolver whose slugs are not the keys opens nothing through the API.
7. **Idempotency upkeep:** schedule `nowo:task-board:api:purge-idempotency`; call `TaskApiIdempotencyStoreInterface::forget(sha256('nowo-task-board-api|' . $tokenId))` when a token is revoked or its person erased.
8. **Board deletes:** `DELETE /v2/list/{id}` erases in slices (202 until done). Two deletes of one board at the same time may both erase the same tasks, so `TASK_ERASED`, with its stored-file refs, and `BOARD_DELETED` can be dispatched twice: make the listeners that delete files or write audit rows idempotent.
9. **Time:** `api.hooks.time_entries` lists and logs only; deleting time is never offered. `Nowo\TaskBoardBundle\Api\Hook\ManualTaskApiTimeEntries` serves the bundle's manual entries for a host without a time store.
10. **Docs for clients:** `docs/openapi.yaml`, and the manual material at the end of [API](API.md#material-for-a-hosts-manual).

## To 2.1.1

Expand Down
9 changes: 9 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`:
Expand Down Expand Up @@ -232,6 +237,10 @@ Overrides and demos must not reintroduce raw `<form>` / `<input>` / `<select>` /
{{ form_end(advanceForm) }}
```

## Task API (since 2.2)

A REST API shaped like ClickUp's, off by default: see [API](API.md).

## Host hooks (since 2.0.0)

```yaml
Expand Down
Loading
Loading