Merge 137-kanban-guards-and-watcher-branches -> main #2
No reviewers
Labels
No labels
blocked:verify-failed
bug
in-progress
triage-approved
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Mark/kanban!2
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "137-kanban-guards-and-watcher-branches"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Auto-opened by batch merger to land 137-kanban-guards-and-watcher-branches into main.
Implements the four named test cases from the parent design task spec (plan §1 / Vikunja#137, kanban t_fc944a5a): (a) webhook→done is terminal across N watcher ticks - Done card never re-arms across 5 sweep ticks: not in watch set, no events dispatched, terminal=true persisted, updated_at unchanged. - Mirrors the spec's rule 1 (done is done) and rule 4 (no re-watching of done cards). (b) blocked card ignores every event except kanban_unblock - Iterates the full KANBAN_EVENT_CATALOG; only kanban_unblock re-subscribes the card. Every other event is a no-op on the watch set and the storage row. - Storage-layer mirror: transitionTask() rejects blocked→active with a typed error referencing kanbanUnblock(); kanbanUnblock() is the SOLE handler that flips blocked→assigned. - kanbanUnblock() also rejects non-blocked cards with a typed error explaining the precondition. (c) running rejects transitions from any non-watcher caller - Forged HTTP: caller=webhook → executing rejected with kind=foreign_running_write. - Other-lane identities (dispatcher, janitor, webhook, user, operator, forged-http-attacker) all rejected. - Missing/null/empty caller rejected with kind=missing_caller_for_running. - Same-shape call from caller=watcher succeeds and sets owner=watcher. - Both running destinations (executing AND executed) covered. (d) after a sweep, done cards do not appear in the watch list - Mixed done/active/blocked fixture; sweep drops done, drops blocked, dispatches watcher-owned running, rejects foreign-owned running (defence-in-depth). - Done cards never re-subscribe on subsequent sweeps (5 ticks). - kanban_unblock re-subscribes a previously-dropped blocked card (the only re-add path). New code: - server/watcher.ts: the in-process Watcher class with Sweep() and deliverKanbanEvent(). Three branches per the spec: drop_done, drop_blocked, dispatch_watcher_process. Foreign-owner running cards are rejected with WatcherError (defence-in-depth). - server/storage.ts: kanbanUnblock() storage method (sole handler for blocked→assigned). transitionTask() now rejects blocked→active with a typed error mentioning kanbanUnblock() so the gate is enforced at the storage layer in addition to the guard layer. - server/__tests__/four_guards_integration.test.ts: 13 new tests covering all four named cases end-to-end. Test summary: 129 passed (was 116); +13 tests, no regressions. Refs: kanban t_fc944a5a, plan §1 / Vikunja#137Round-1 review flagged that the HTTP routes trusted the body's self-attested caller field: a forged request that posts {"to_state":"executing","caller":"watcher"} slipped past the guard with owner=watcher written to the card. Same gap on PATCH /flags which accepted owner from the body with no auth. The four-guard state model is correct at the guard layer. The defect was upstream: the HTTP boundary had no notion of caller identity and trusted the body's self-attestation. This commit closes that boundary. Fix: - shared/schema.ts: drop caller from insertTransitionSchema. The body field is now silently stripped by zod (unknown keys are ignored, not rejected, so the change is non-breaking for clients). - New server/principal.ts: deriveCallerFromRequest(req) reads the caller identity from the X-Internal-Actor header (closed set: "watcher" only today) and defaults to "unknown". deriveOwnerFromRequest applies the same rule to PATCH /flags' owner field. The module documents the remaining auth gap explicitly so the next reader does not mistake "header-derived caller" for "authenticated caller". - server/routes.ts: * POST /api/tasks/:task_id/transition: caller is now derived from the header, not from parsed.caller. * PATCH /api/tasks/:task_id/flags: owner is server-derived (or undefined - body field is dropped regardless of value). - server/storage.ts: replace the dynamic await import(...) of transition_guards (style nit from the review) with a top-level ESM import - no circular dep risk, easier to mock and audit. Tests (24 new tests, 153 total): - server/__tests__/principal.test.ts (16 tests): pure-logic coverage of the trust-boundary helper - missing header, empty/whitespace value, comma-joined duplicate (header smuggling), unknown principal, array path, case-different values (closed-set equality, not normalized), trimming. - server/__tests__/routes.test.ts (8 new tests in 2 new describe blocks): forged-but-lies attack (body claims caller=watcher, route must pass caller=unknown to storage), default-deny without header, legitimate watcher via header, forged X-Internal-Actor value, body caller field ignored even with a valid header, PATCH /flags owner dropped without header, PATCH /flags owner set via header (body field ignored), PATCH /flags owner with bad type silently dropped. Acceptance from the reviewer's required correction list - all addressed: 1. Server-controlled caller identity at HTTP boundary. Yes. 2. Storage-layer caller parameter preserved. Yes. 3. Forged-but-lies test that the route rejects body-supplied caller=watcher. Yes (explicit assertion: expect(caller).toBe("unknown") inside the mock). 4. PATCH /flags fix: owner is server-derived; body field dropped. Yes. Verification: 153/153 tests pass (was 129); typecheck clean (3 pre-existing multer strictness errors on origin/main unchanged).Refactor server/watcher.ts Sweep() into three named branch methods: - branch_done(card) -> drop_done - branch_blocked(card) -> drop_blocked - branch_running(card) -> dispatch_watcher_process / abort Branch 3 (running) now asserts card.owner === watcher at entry. Assertion failure throws WatcherError{kind:'foreign_running_write'} which sweep() catches per-card, so the tick for that card ABORTS (per card-body spec) without silently continuing. Other cards in the same tick still process. Test surface expanded with a new (e) describe block: - failing input (owner != watcher) -> assertion fires, abort_running_owner_assertion action emitted, watch set untouched - mixed-tick abort isolation: failing card's tick aborts but the good card in the same tick dispatches normally - null owner also triggers the assertion (defence-in-depth) - happy path (owner == watcher) dispatches without throwing Acceptance: 1. Three named branches clearly visible (branch_done/blocked/running at server/watcher.ts:137/150/170; sweep() routes by name at 218/223/231). 2. _watchSet.add happens ONLY in branch_running (line 181) and inside the 'kanban_unblock' arm of deliverKanbanEvent (line 287) — no timer, no sweep, no other event path may re-add a blocked card. 3. Running-tick owner assertion: 4 new tests pin the failing-input surface. Tests: 157/157 vitest pass (was 153; +4 new). tsc: pre-existing routes.ts:22,218,231 errors unchanged; 0 new.