wood-fired-tasks 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/AGENTS.md +10 -0
  2. package/CHANGELOG.md +97 -3
  3. package/README.md +2 -1
  4. package/SECURITY.md +3 -3
  5. package/dist/api/api-response.d.ts +4 -0
  6. package/dist/api/routes/auth/callback.js +11 -1
  7. package/dist/api/routes/auth/device-code.d.ts +17 -1
  8. package/dist/api/routes/auth/device-code.js +26 -3
  9. package/dist/api/routes/auth/device-html.js +11 -1
  10. package/dist/api/routes/auth/device-token.js +14 -1
  11. package/dist/api/routes/auth/login.js +13 -1
  12. package/dist/api/routes/models/index.d.ts +16 -0
  13. package/dist/api/routes/models/index.js +1 -7
  14. package/dist/api/routes/projects/resolve-model.js +17 -8
  15. package/dist/api/routes/projects/wsjf.js +4 -0
  16. package/dist/api/routes/settings/model-policy.js +8 -4
  17. package/dist/api/routes/tasks/schemas.d.ts +8 -0
  18. package/dist/api/routes/tasks/schemas.js +10 -0
  19. package/dist/api/server.d.ts +1 -1
  20. package/dist/api/server.js +40 -21
  21. package/dist/cli/api/client.d.ts +7 -7
  22. package/dist/cli/api/types.d.ts +6 -0
  23. package/dist/cli/auth/browser-open.js +21 -15
  24. package/dist/cli/auth/credentials.d.ts +19 -0
  25. package/dist/cli/auth/credentials.js +30 -0
  26. package/dist/cli/commands/comment-add.js +8 -1
  27. package/dist/cli/commands/create.js +9 -1
  28. package/dist/cli/commands/list.js +30 -7
  29. package/dist/cli/commands/login.d.ts +10 -0
  30. package/dist/cli/commands/login.js +2 -1
  31. package/dist/cli/commands/models.d.ts +38 -3
  32. package/dist/cli/commands/models.js +48 -4
  33. package/dist/cli/commands/project-set-models.js +6 -19
  34. package/dist/cli/commands/settings-set-models.js +6 -19
  35. package/dist/cli/commands/setup.d.ts +8 -0
  36. package/dist/cli/commands/setup.js +1 -0
  37. package/dist/cli/commands/update.js +20 -0
  38. package/dist/config/env.d.ts +7 -0
  39. package/dist/config/env.js +63 -0
  40. package/dist/events/event-bus.d.ts +2 -1
  41. package/dist/events/event-bus.js +1 -0
  42. package/dist/events/sse-manager.d.ts +30 -1
  43. package/dist/events/sse-manager.js +100 -18
  44. package/dist/events/types.d.ts +19 -2
  45. package/dist/events/types.js +1 -0
  46. package/dist/index.d.ts +10 -0
  47. package/dist/index.js +36 -1
  48. package/dist/mcp/index.js +5 -24
  49. package/dist/mcp/lib/model-tool-definitions.d.ts +122 -0
  50. package/dist/mcp/lib/model-tool-definitions.js +112 -0
  51. package/dist/mcp/remote/register-tools.js +14 -73
  52. package/dist/mcp/remote/rest-client.d.ts +4 -10
  53. package/dist/mcp/resources/events.js +2 -1
  54. package/dist/mcp/tools/health-tools.d.ts +14 -0
  55. package/dist/mcp/tools/health-tools.js +52 -1
  56. package/dist/mcp/tools/model-tools.d.ts +4 -3
  57. package/dist/mcp/tools/model-tools.js +13 -82
  58. package/dist/mcp/tools/task-tools.js +5 -1
  59. package/dist/repositories/interfaces.d.ts +39 -0
  60. package/dist/repositories/project-charter-history.repository.js +2 -12
  61. package/dist/repositories/project.repository.js +16 -48
  62. package/dist/repositories/task.repository.d.ts +48 -0
  63. package/dist/repositories/task.repository.js +90 -18
  64. package/dist/repositories/wsjf-history.repository.js +6 -16
  65. package/dist/schemas/model-catalog.schema.d.ts +22 -0
  66. package/dist/schemas/model-catalog.schema.js +21 -0
  67. package/dist/schemas/model-policy.schema.d.ts +14 -0
  68. package/dist/schemas/model-policy.schema.js +11 -0
  69. package/dist/schemas/task.schema.d.ts +13 -1
  70. package/dist/schemas/task.schema.js +11 -0
  71. package/dist/services/claim-release.service.d.ts +11 -0
  72. package/dist/services/claim-release.service.js +31 -2
  73. package/dist/services/job-size-backfill.d.ts +43 -0
  74. package/dist/services/job-size-backfill.js +81 -0
  75. package/dist/services/model-catalog.service.d.ts +28 -8
  76. package/dist/services/model-catalog.service.js +16 -4
  77. package/dist/services/model-policy.service.d.ts +87 -9
  78. package/dist/services/model-policy.service.js +96 -4
  79. package/dist/services/settings.service.d.ts +3 -2
  80. package/dist/services/settings.service.js +30 -10
  81. package/dist/services/task.service.d.ts +87 -3
  82. package/dist/services/task.service.js +351 -16
  83. package/dist/services/wsjf-health.service.d.ts +7 -1
  84. package/dist/services/wsjf-health.service.js +27 -0
  85. package/dist/services/wsjf.service.d.ts +19 -0
  86. package/dist/services/wsjf.service.js +33 -0
  87. package/dist/skills/tasks/blocked.md +2 -1
  88. package/dist/skills/tasks/decompose.md +30 -4
  89. package/dist/skills/tasks/loop-dag.md +3 -1
  90. package/dist/skills/tasks/loop-shared.md +8 -0
  91. package/dist/skills/tasks/loop.md +1 -1
  92. package/dist/skills/tasks/update.md +8 -0
  93. package/dist/slack/commands/tasks-command.js +11 -7
  94. package/dist/slack/formatters/project-formatter.js +7 -4
  95. package/dist/slack/mrkdwn.d.ts +1 -0
  96. package/dist/slack/mrkdwn.js +26 -0
  97. package/dist/slack/notifier.js +1 -0
  98. package/dist/slack/task-formatter.js +13 -11
  99. package/dist/types/task.d.ts +12 -17
  100. package/dist/types/wsjf.d.ts +1 -1
  101. package/dist/types/wsjf.js +2 -0
  102. package/dist/utils/parse-json-column.d.ts +25 -0
  103. package/dist/utils/parse-json-column.js +39 -0
  104. package/docs/API.md +23 -10
  105. package/docs/ARCHITECTURE.md +16 -14
  106. package/docs/CLI.md +11 -12
  107. package/docs/INTERFACES.md +9 -2
  108. package/docs/MCP.md +105 -4
  109. package/docs/SETUP.md +39 -27
  110. package/docs/SLACK.md +1 -0
  111. package/package.json +5 -5
  112. package/packages/wft-router/README.md +14 -0
  113. package/packages/wft-router/dist/bin/wft-router.d.ts +10 -0
  114. package/packages/wft-router/dist/bin/wft-router.js +18 -0
  115. package/packages/wft-router/dist/config/event-types.d.ts +1 -1
  116. package/packages/wft-router/dist/config/event-types.js +1 -0
  117. package/packages/wft-router/dist/config/triggers-schema.d.ts +13 -0
  118. package/packages/wft-router/dist/config/triggers-schema.js +82 -0
  119. package/packages/wft-router/dist/daemon.d.ts +88 -0
  120. package/packages/wft-router/dist/daemon.js +175 -3
  121. package/packages/wft-router/dist/dispatch/startup-sweep.d.ts +96 -0
  122. package/packages/wft-router/dist/dispatch/startup-sweep.js +163 -0
  123. package/packages/wft-router/dist/index.d.ts +2 -2
  124. package/packages/wft-router/dist/index.js +1 -1
  125. package/packages/wft-router/dist/metrics.d.ts +64 -4
  126. package/packages/wft-router/dist/metrics.js +0 -0
  127. package/packages/wft-router/dist/sse/client.d.ts +19 -0
  128. package/packages/wft-router/dist/sse/client.js +57 -3
  129. package/packages/wft-router/dist/sse/index.d.ts +1 -1
  130. package/packages/wft-router/dist/sse/index.js +1 -1
package/AGENTS.md CHANGED
@@ -68,6 +68,16 @@ Treat these as off-limits unless your task explicitly requires touching them.
68
68
  - `/bin/` — gitignored client install artifacts. `bin/tasks.cmd` may contain a **baked-in API key** from a local install; do not commit, share, or read its contents into context.
69
69
  - `.planning/`, `.claude/`, `.codex/`, `.agents/`, `.bug-smash-*.md` — gitignored workspace dirs. Not part of the shipped repo; do not rely on them.
70
70
 
71
+ ## Trust boundary for static / security reviewers
72
+
73
+ If you are evaluating the **trust** of this checkout (static or security review of a repo you do **not** operate), READ the repo — do **not** execute its host-mutating or package-executing flows. These are intentional, trusted-operator operations and are individually banner-marked at their definition:
74
+
75
+ - `tasks self-update` / `/tasks:update` ([`skills/tasks/update.md`](skills/tasks/update.md)) — global npm install that mutates the installed CLI.
76
+ - The loop's artifact-level distributable smoke ([`skills/tasks/loop-shared.md`](skills/tasks/loop-shared.md) §O.2b) — `npm pack`, temp-prefix global install, and running the shipped binary.
77
+ - Deployment scripts ([`deploy/`](deploy/README.md)) — `sudo`, systemd service control, DB migrations/restores, production dependency installs.
78
+
79
+ Running any of the above against an untrusted checkout executes repo-authored code with real side effects. A trust review's job is to read and reason about these paths, never to run them.
80
+
71
81
  ## Deeper docs
72
82
 
73
83
  | File | One-line purpose |
package/CHANGELOG.md CHANGED
@@ -11,7 +11,100 @@ vulnerabilities, supply-chain pinning) are always called out under `Security`.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
- _No changes yet._
14
+ ## [v2.3.0] - 2026-06-27
15
+
16
+ Security-hardening release remediating issues #74, #72, and #75 (reported by the
17
+ Stafficy agent). All changes are backward compatible — defaults reproduce prior
18
+ behavior.
19
+
20
+ ### Added
21
+ - **Proxy-aware rate limiting** (#75). New `TRUST_PROXY` env var (default **off**;
22
+ accepts `true`, an integer hop count, or a comma-separated IP/CIDR allowlist)
23
+ wired into the Fastify factory so `request.ip` derives from `X-Forwarded-For`
24
+ only when a proxy is trusted. The rate limiter gains an explicit `keyGenerator`
25
+ that keys on the authenticated principal when present, else `request.ip`.
26
+ - **Per-route auth/device rate limits** (#75). `/auth/login`, `/auth/callback`,
27
+ `/auth/device/code`, and `/auth/device/verify` get a tighter budget
28
+ (`RATE_LIMIT_AUTH_MAX`, default 10); `/auth/device/token` gets a looser one for
29
+ CLI polling (`RATE_LIMIT_DEVICE_TOKEN_MAX`, default 30). `RATE_LIMIT_MAX` and
30
+ `RATE_LIMIT_TIME_WINDOW` are now part of the validated `src/config/env.ts`
31
+ schema instead of raw `process.env` reads.
32
+
33
+ ### Security
34
+ - **Slack mrkdwn injection** (#74). User-controlled task/project/comment text
35
+ (titles, descriptions, names, comment author + body, assignee, tags) is now
36
+ escaped via a new `escapeSlackMrkdwn` helper before being placed in Slack
37
+ mrkdwn — including the broadcast notification path. Prevents injected Slack
38
+ mentions (`<!channel>`) and spoofed links from appearing in trusted-looking
39
+ notifications. System-owned fields (ids, status/priority enums) are unaffected.
40
+ - **`triggers.yaml` trust boundary enforced** (#72, wft-router). The router now
41
+ rejects its `triggers.yaml` at startup if it is group/other-accessible
42
+ (`mode & 0o077`) or, on POSIX, not owned by the router user — the path is
43
+ realpath-resolved so a benign symlink to an attacker-writable target is also
44
+ rejected. Closes the gap where `docs/event-router-design.md` documented `0600`
45
+ enforcement that did not exist. Windows skips the check (documented deployment
46
+ requirement). Since `triggers.yaml` can run `shell_exec` and arbitrary webhook
47
+ egress, this restores the documented edit-equals-code-execution trust boundary.
48
+ - **Rate-limit hardening for auth routes** (#75). Sensitive auth/device endpoints
49
+ no longer share the broad global budget, narrowing brute-force and
50
+ user-code-enumeration surface; proxy-aware keying prevents all clients behind a
51
+ reverse proxy from collapsing into one bucket (accidental DoS / uneven
52
+ protection).
53
+
54
+ ## [v2.2.0] - 2026-06-11
55
+
56
+ ### Added
57
+ - **Guaranteed task sizing.** Every task now carries a server-derived job size
58
+ so the Configurable-Task-Models `resolve_model` size routing (shipped in
59
+ v2.1.0) engages on the live backlog, while each task stays honestly
60
+ *unscored* for WSJF ranking: a deterministic `minutesToTier` mapping,
61
+ `auto_size` / `boot_sweep` history triggers, a server-internal size-only
62
+ write path, a `decomp-*` submission-contract gate, auto-sizing of WSJF-less
63
+ creates, a minutes-vs-jobSize conflict gate, recompute on `update_task` when
64
+ `estimated_minutes` changes (manual sizes are never clobbered), an idempotent
65
+ **boot sweep** that backfills NULL job sizes on every server start, and a
66
+ `wsjf_health` auto-sized-pending finding.
67
+ - **`wft list --status all`** explicit sentinel and a `statusFilter` echo in
68
+ `--json` output (task #1006). The default `wft list` view already returns
69
+ every status — open, in_progress, blocked, done, and closed — and the
70
+ `--help` text now states this plainly so machine consumers don't read a
71
+ status transition (e.g. open → blocked) as a deleted task. `--status all`
72
+ is accepted as a self-documenting way to ask for "every status", and the
73
+ JSON envelope's `metadata.statusFilter` names the effective filter
74
+ (`all` or the requested status). The default view is unchanged.
75
+
76
+ ### Changed
77
+ - **Configurable Task Models hardening** (PR-#55 review follow-ups, #928–#933):
78
+ `resolve_model` parity hardening for missing-project and foreign/nonexistent
79
+ `task_id`; single-sourced `PIPELINE_ROLES` / `FAMILY_LADDER` /
80
+ `DEFAULT_MODEL_MAP` with a code-level `resolveAuto`; the `modelPolicyService`
81
+ is now wired once in `createApp` with a memoized global policy and a prepared
82
+ resolver lookup; the model-catalog wire shape, model-tool definitions, and
83
+ JSON-column parser are single-sourced.
84
+ - **wft-router**: real-event age gauge, start time, by-kind split, debug
85
+ pings, and stale-subscription resubscribe; plus an opt-in cold-start sweep
86
+ that dispatches pre-existing open backlogs so a freshly started router does
87
+ not miss work created while it was down.
88
+
89
+ ### Fixed
90
+ - **SSE**: close the stream on every server-initiated eviction and align the
91
+ buffer TTL with the connection age window, so long-lived clients no longer
92
+ silently go deaf at the eviction boundary (#1001).
93
+ - **Claims**: same-assignee renewal, a `task.claim_released` event, and TTL
94
+ visibility — long-running workers no longer lose their claim without a signal
95
+ (#1003).
96
+ - **Atomic block-with-dependency** via `update_task blocked_by`: a `blocked`
97
+ status without a blocking edge is rejected rather than becoming a dead end
98
+ (#1004).
99
+ - **Non-interactive `tasks create` / `tasks comment-add` default attribution to
100
+ the logged-in identity** (task #1007). Scripted runs no longer fail with
101
+ "Missing required field: created-by" (or `author`) when `--created-by` /
102
+ `--author` is omitted but credentials are present — the value defaults to the
103
+ credentials display name (email fallback), the same identity `tasks whoami`
104
+ reports. The original error is preserved only when no identity can be resolved
105
+ (no credentials file).
106
+ - **wft-router**: spell the NUL label-separator as a `\u0000` escape rather than
107
+ a raw byte.
15
108
 
16
109
  ## [v2.1.1] - 2026-06-09
17
110
 
@@ -65,8 +158,9 @@ _No changes yet._
65
158
  - **Test suite no longer launches the developer's real browser.**
66
159
  `setup.remote.test.ts` drove the device flow with `openBrowser: true`, so
67
160
  every `npm test` on a DISPLAY-set desktop spawned `xdg-open` at a mock
68
- server. `openBrowser()` now honors `WFT_NO_BROWSER`, set for every test via
69
- `vitest.setup.ts`.
161
+ server. `runDeviceLogin` now takes an injectable `opener` seam (defaulting to
162
+ the real `openBrowser`); tests inject a stub instead of relying on a global
163
+ env flag that, if leaked, would silently disable browser login for real users.
70
164
  - **Documentation counts drift.** README / `docs/INTERFACES.md` / `docs/MCP.md`
71
165
  had stale tool (27 vs 31), route (52/45 vs 59/52), and CLI command (42 vs 45)
72
166
  counts, plus a stale "model tools are stdio-only" claim from before remote
package/README.md CHANGED
@@ -658,7 +658,8 @@ Wood Fired Tasks streams real-time task and project change notifications via Ser
658
658
  | task.updated | Task fields modified |
659
659
  | task.deleted | Task deleted |
660
660
  | task.status_changed | Task status transition |
661
- | task.claimed | Task claimed by agent |
661
+ | task.claimed | Task claimed by agent (also emitted on a same-assignee claim renewal) |
662
+ | task.claim_released | Stale claim auto-released by the TTL sweep (carries `previous_assignee`, `expired_claimed_at`, `released_at`) |
662
663
  | project.created | New project created |
663
664
  | project.updated | Project modified |
664
665
  | project.deleted | Project deleted |
package/SECURITY.md CHANGED
@@ -14,11 +14,11 @@ security updates. Older tags are provided as-is.
14
14
  | Version | Supported |
15
15
  | ----------------- | ------------------ |
16
16
  | `main` (HEAD) | :white_check_mark: |
17
- | `v2.1.1` (latest) | :white_check_mark: |
18
- | `v1.0` – `v2.0.6` | :x: |
17
+ | `v2.3.0` (latest) | :white_check_mark: |
18
+ | `v1.0` – `v2.2.0` | :x: |
19
19
 
20
20
  "Latest" tracks whichever tag is most recent on GitHub; at the time of
21
- writing that is `v2.1.1`. If you are reading this on an older checkout,
21
+ writing that is `v2.3.0`. If you are reading this on an older checkout,
22
22
  verify the current latest release via
23
23
  `git tag --sort=-creatordate | head -1` or the GitHub Releases page.
24
24
 
@@ -94,6 +94,8 @@ export declare function parseTaskResponse(payload: unknown, endpoint: string): {
94
94
  verifier_request_id?: string | undefined;
95
95
  verified_at?: string | undefined;
96
96
  } | null;
97
+ claim_ttl_minutes?: number | undefined;
98
+ claim_remaining_seconds?: number | undefined;
97
99
  };
98
100
  /** Convenience: validate a single project response body. */
99
101
  export declare function parseProjectResponse(payload: unknown, endpoint: string): {
@@ -186,6 +188,8 @@ export declare function parseTaskListResponse(payload: unknown, endpoint: string
186
188
  verifier_request_id?: string | undefined;
187
189
  verified_at?: string | undefined;
188
190
  } | null;
191
+ claim_ttl_minutes?: number | undefined;
192
+ claim_remaining_seconds?: number | undefined;
189
193
  }[];
190
194
  total: number;
191
195
  limit: number;
@@ -1,8 +1,18 @@
1
1
  import { handleCallback } from '../../../services/oidc-client.js';
2
2
  import { upsertFromOidc } from '../../../services/user-upsert.js';
3
3
  import { toAuthenticatedUser } from '../../plugins/auth/strategies/pat.js';
4
+ import { config } from '../../../config/env.js';
4
5
  const callbackRoute = async (fastify, opts) => {
5
- fastify.get('/callback', { config: { skipAuth: true } }, async (request, reply) => {
6
+ // Issue #75 tighter per-route rate limit (auth surface hardening).
7
+ fastify.get('/callback', {
8
+ config: {
9
+ skipAuth: true,
10
+ rateLimit: {
11
+ max: config.RATE_LIMIT_AUTH_MAX,
12
+ timeWindow: config.RATE_LIMIT_AUTH_TIME_WINDOW,
13
+ },
14
+ },
15
+ }, async (request, reply) => {
6
16
  const handshake = request.session.get('oidc.handshake');
7
17
  if (!handshake) {
8
18
  request.log.error({ requestId: request.id, peerIp: request.ip }, 'oidc.handshake_missing');
@@ -42,6 +42,16 @@ export interface DeviceCodeRouteOptions {
42
42
  * one OIDC_CLIENT_ID; we reject anything else).
43
43
  */
44
44
  expectedClientId: string;
45
+ /**
46
+ * Issue #68 (finding 2) — optional allowlist of hostnames the per-request
47
+ * verification origin may be built from (sourced from
48
+ * `env.DEVICE_FLOW_TRUSTED_HOSTS`). When non-empty, a request whose
49
+ * `Host` / `X-Forwarded-Host` is not in the list is ignored and the
50
+ * verification URI falls back to the configured {@link origin}. When
51
+ * empty/undefined (default) every Host is honored — backward compatible.
52
+ * Hostnames only (no port); the resolver strips any `:port` before matching.
53
+ */
54
+ trustedHosts?: readonly string[];
45
55
  }
46
56
  /**
47
57
  * Resolve the origin (`scheme://host[:port]`) the CLIENT used to reach this
@@ -58,10 +68,16 @@ export interface DeviceCodeRouteOptions {
58
68
  * is returned ONLY to the same client that sent the request, so a spoofed Host
59
69
  * merely misdirects the spoofer. Falls back to `fallback` (the configured
60
70
  * origin) when no Host header is present at all.
71
+ *
72
+ * Issue #68 (finding 2) — an operator who wants to pin the trust boundary may
73
+ * pass `trustedHosts` (from `env.DEVICE_FLOW_TRUSTED_HOSTS`). When that list is
74
+ * non-empty, a Host whose hostname is NOT on it is refused and we fall back to
75
+ * the configured `fallback` origin rather than echoing an arbitrary header.
76
+ * When the list is empty/omitted the behavior is unchanged (every Host honored).
61
77
  */
62
78
  export declare function resolveVerificationOrigin(request: {
63
79
  headers: Record<string, string | string[] | undefined>;
64
80
  protocol?: string;
65
- }, fallback: string): string;
81
+ }, fallback: string, trustedHosts?: readonly string[]): string;
66
82
  declare const deviceCodeRoute: FastifyPluginAsync<DeviceCodeRouteOptions>;
67
83
  export default deviceCodeRoute;
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { createSession } from '../../../services/device-flow-store.js';
3
+ import { config } from '../../../config/env.js';
3
4
  /** First value of a possibly comma-joined / array-valued HTTP header. */
4
5
  function firstHeaderValue(v) {
5
6
  const raw = Array.isArray(v) ? v[0] : v;
@@ -23,12 +24,25 @@ function firstHeaderValue(v) {
23
24
  * is returned ONLY to the same client that sent the request, so a spoofed Host
24
25
  * merely misdirects the spoofer. Falls back to `fallback` (the configured
25
26
  * origin) when no Host header is present at all.
27
+ *
28
+ * Issue #68 (finding 2) — an operator who wants to pin the trust boundary may
29
+ * pass `trustedHosts` (from `env.DEVICE_FLOW_TRUSTED_HOSTS`). When that list is
30
+ * non-empty, a Host whose hostname is NOT on it is refused and we fall back to
31
+ * the configured `fallback` origin rather than echoing an arbitrary header.
32
+ * When the list is empty/omitted the behavior is unchanged (every Host honored).
26
33
  */
27
- export function resolveVerificationOrigin(request, fallback) {
34
+ export function resolveVerificationOrigin(request, fallback, trustedHosts = []) {
28
35
  const host = firstHeaderValue(request.headers['x-forwarded-host']) ??
29
36
  firstHeaderValue(request.headers['host']);
30
37
  if (!host)
31
38
  return fallback;
39
+ // When an allowlist is configured, the Host's hostname (sans :port) must be
40
+ // on it; otherwise refuse the header and use the configured origin.
41
+ if (trustedHosts.length > 0) {
42
+ const hostname = (host.split(':')[0] ?? host).toLowerCase();
43
+ if (!trustedHosts.includes(hostname))
44
+ return fallback;
45
+ }
32
46
  const scheme = firstHeaderValue(request.headers['x-forwarded-proto']) ??
33
47
  (request.protocol && request.protocol.length > 0 ? request.protocol : 'http');
34
48
  return `${scheme}://${host}`;
@@ -44,7 +58,16 @@ const BodySchema = z.object({
44
58
  scope: z.string().optional(),
45
59
  });
46
60
  const deviceCodeRoute = async (fastify, opts) => {
47
- fastify.post('/auth/device/code', { config: { skipAuth: true } }, async (request, reply) => {
61
+ // Issue #75 tighter per-route rate limit (auth surface hardening).
62
+ fastify.post('/auth/device/code', {
63
+ config: {
64
+ skipAuth: true,
65
+ rateLimit: {
66
+ max: config.RATE_LIMIT_AUTH_MAX,
67
+ timeWindow: config.RATE_LIMIT_AUTH_TIME_WINDOW,
68
+ },
69
+ },
70
+ }, async (request, reply) => {
48
71
  // Manual Zod parse so the error envelope matches RFC 8628 verbatim
49
72
  // (`{error: 'invalid_request'}`) — Fastify's default 400 carries the
50
73
  // `statusCode/error/message` triplet which is not what RFC 8628 wants.
@@ -73,7 +96,7 @@ const deviceCodeRoute = async (fastify, opts) => {
73
96
  // #834: build the verification URL from the address the CLIENT connected to
74
97
  // (request Host / X-Forwarded-*), not the static configured origin, so a
75
98
  // remote/LAN client gets a URL it can actually open instead of localhost.
76
- const origin = resolveVerificationOrigin(request, opts.origin);
99
+ const origin = resolveVerificationOrigin(request, opts.origin, opts.trustedHosts ?? []);
77
100
  return reply.code(200).send({
78
101
  device_code: session.deviceCode,
79
102
  user_code: session.userCode,
@@ -3,6 +3,7 @@ import { renderDevicePage, renderDeviceApprovedPage } from '../../../web/pages/d
3
3
  import { getOrCreateCsrfToken, verifyCsrfToken } from './csrf.js';
4
4
  import { requireUser } from '../../plugins/auth.js';
5
5
  import { generateToken } from '../../../services/pat-hash.js';
6
+ import { config } from '../../../config/env.js';
6
7
  /**
7
8
  * Strict alphabet check for `?user_code=`. MUST match the alphabet used by
8
9
  * device-flow-store's generator: 31 confusable-free uppercase chars,
@@ -70,7 +71,16 @@ const deviceHtmlRoute = async (fastify, opts) => {
70
71
  // 4. Success → approve() then renderDeviceApprovedPage(). Logger emits
71
72
  // `event: device_flow_approved` with the userId — user_code is
72
73
  // DELIBERATELY OMITTED from the log payload (Threat T-30-02-06).
73
- fastify.post('/auth/device/verify', { config: { sessionOnly: true } }, async (request, reply) => {
74
+ // Issue #75 tighter per-route rate limit (auth surface hardening).
75
+ fastify.post('/auth/device/verify', {
76
+ config: {
77
+ sessionOnly: true,
78
+ rateLimit: {
79
+ max: config.RATE_LIMIT_AUTH_MAX,
80
+ timeWindow: config.RATE_LIMIT_AUTH_TIME_WINDOW,
81
+ },
82
+ },
83
+ }, async (request, reply) => {
74
84
  const body = (request.body ?? {});
75
85
  // 1. CSRF gate.
76
86
  if (!verifyCsrfToken(request, body._csrf)) {
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { findByDeviceCode, remove } from '../../../services/device-flow-store.js';
3
3
  import { toAuthenticatedUser } from '../../plugins/auth/strategies/pat.js';
4
+ import { config } from '../../../config/env.js';
4
5
  /** RFC 8628 §3.4 grant_type literal. */
5
6
  const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
6
7
  /**
@@ -20,7 +21,19 @@ const deviceTokenRoute = async (fastify, opts) => {
20
21
  if (!fastify.hasDecorator('userRepository')) {
21
22
  throw new Error('deviceTokenRoute requires userRepository to be decorated before registration');
22
23
  }
23
- fastify.post('/auth/device/token', { config: { skipAuth: true } }, async (request, reply) => {
24
+ // Issue #75 the CLI polls this endpoint every `interval` seconds, so it
25
+ // gets a looser per-route budget than the other auth routes (still well
26
+ // below the global max). Env-tunable via RATE_LIMIT_DEVICE_TOKEN_MAX /
27
+ // RATE_LIMIT_AUTH_TIME_WINDOW.
28
+ fastify.post('/auth/device/token', {
29
+ config: {
30
+ skipAuth: true,
31
+ rateLimit: {
32
+ max: config.RATE_LIMIT_DEVICE_TOKEN_MAX,
33
+ timeWindow: config.RATE_LIMIT_AUTH_TIME_WINDOW,
34
+ },
35
+ },
36
+ }, async (request, reply) => {
24
37
  // request.body is either the parsed JSON object OR the formbody plugin's
25
38
  // parsed URLSearchParams shape — both surface as plain objects to Zod.
26
39
  const parsed = BodySchema.safeParse(request.body);
@@ -1,4 +1,5 @@
1
1
  import { buildAuthorizationUrl, calculatePKCECodeChallenge, randomNonce, randomPKCECodeVerifier, randomState, } from '../../../services/oidc-client.js';
2
+ import { config } from '../../../config/env.js';
2
3
  /**
3
4
  * Validates `?next=<path>` for open-redirect safety. Pattern: a SINGLE
4
5
  * leading slash followed by ANY character that is NOT another slash AND
@@ -33,7 +34,18 @@ const NEXT_PATH_RE = /^\/[^/\\]/;
33
34
  */
34
35
  const DEVICE_NEXT_RE = /^\/auth\/device(\?user_code=[A-HJ-KM-NP-Z2-9]{8})?$/;
35
36
  const loginRoute = async (fastify, opts) => {
36
- fastify.get('/login', { config: { skipAuth: true } }, async (request, reply) => {
37
+ // Issue #75 tighter per-route rate limit than the global budget
38
+ // (brute-force / DoS hardening on the sensitive auth surface). Env-tunable
39
+ // via RATE_LIMIT_AUTH_MAX / RATE_LIMIT_AUTH_TIME_WINDOW.
40
+ fastify.get('/login', {
41
+ config: {
42
+ skipAuth: true,
43
+ rateLimit: {
44
+ max: config.RATE_LIMIT_AUTH_MAX,
45
+ timeWindow: config.RATE_LIMIT_AUTH_TIME_WINDOW,
46
+ },
47
+ },
48
+ }, async (request, reply) => {
37
49
  // Already signed in — short-circuit to /me (the documented home
38
50
  // page for authenticated users). Skip the IdP roundtrip entirely.
39
51
  if (request.session.get('user')) {
@@ -1,5 +1,21 @@
1
1
  import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
2
2
  import { z } from 'zod';
3
+ /**
4
+ * Configurable Task Models (Task 13) — GET /api/v1/models
5
+ *
6
+ * Exposes the runtime-discovered Claude model catalog (task #917's
7
+ * `model-catalog.service`) over REST so the remote MCP proxy / dashboard /
8
+ * `set-models` interview can enumerate the available models. The 200 body is
9
+ * `{ models, stale }` — identical to the service's `ModelCatalog` and the
10
+ * stdio MCP `list_models` tool's structured output.
11
+ *
12
+ * `stale: true` signals the catalog was served from the static fallback (no
13
+ * ANTHROPIC_API_KEY, non-OK Models API response, or a network error). The
14
+ * service NEVER throws from `list()`, so this route always returns 200.
15
+ *
16
+ * Auth: inherits the standard `/api/v1` auth chain (the parent plugin is
17
+ * mounted inside the `/api/v1` scope that wires `authPlugin`). No custom guard.
18
+ */
3
19
  /** The `{ models, stale }` envelope returned by GET /models. */
4
20
  export declare const ModelCatalogResponseSchema: z.ZodObject<{
5
21
  models: z.ZodArray<z.ZodObject<{
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { ModelCatalogEntrySchema } from '../../../schemas/model-catalog.schema.js';
2
3
  /**
3
4
  * Configurable Task Models (Task 13) — GET /api/v1/models
4
5
  *
@@ -15,13 +16,6 @@ import { z } from 'zod';
15
16
  * Auth: inherits the standard `/api/v1` auth chain (the parent plugin is
16
17
  * mounted inside the `/api/v1` scope that wires `authPlugin`). No custom guard.
17
18
  */
18
- /** One discovered model, mirroring `ModelCatalogEntry` from the catalog service. */
19
- const ModelCatalogEntrySchema = z.object({
20
- id: z.string(),
21
- display_name: z.string(),
22
- family: z.string(),
23
- created_at: z.string(),
24
- });
25
19
  /** The `{ models, stale }` envelope returned by GET /models. */
26
20
  export const ModelCatalogResponseSchema = z.object({
27
21
  models: z.array(ModelCatalogEntrySchema),
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { ErrorResponseSchema } from '../tasks/schemas.js';
3
+ import { PipelineRoleSchema } from '../../../schemas/model-policy.schema.js';
3
4
  /**
4
5
  * Configurable Task Models (Task #926) — GET /api/v1/projects/:id/resolve-model
5
6
  *
@@ -21,10 +22,15 @@ import { ErrorResponseSchema } from '../tasks/schemas.js';
21
22
  * wires in-process (project policy ?? global default, per-slot merge, jobSize→
22
23
  * category routing when `task_id` is supplied).
23
24
  *
24
- * Project-existence is enforced explicitly so a missing project yields a 404
25
- * ProblemDetails (mirrors the sibling `/:id/topology` and `/:id/dependency-graph`
26
- * routes). Without the guard an unknown project would resolve to the global
27
- * default (or null) silently rather than signalling the bad id.
25
+ * Project-existence is enforced by the resolver itself (task #928): a missing
26
+ * project throws NotFoundError → 404 ProblemDetails (mirrors the sibling
27
+ * `/:id/topology` and `/:id/dependency-graph` routes), so an unknown project
28
+ * never resolves to the global default (or null) silently. The route carries
29
+ * no separate pre-fetch guard (task #931): the resolver's single project
30
+ * fetch doubles as the 404 check. The resolver also validates `task_id`: a
31
+ * nonexistent one throws NotFoundError (→ 404) and one belonging to a
32
+ * different project throws ValidationError (→ 400), so size-routing can never
33
+ * silently use a foreign task's jobSize.
28
34
  *
29
35
  * Auth: inherits the standard projects-route auth chain (the parent
30
36
  * `projectRoutes` plugin is mounted inside the `/api/v1` scope that wires
@@ -43,18 +49,21 @@ const resolveModelRoutes = async (fastify) => {
43
49
  'MCP tool output. Read-only.',
44
50
  params: z.object({ id: z.coerce.number().int().positive() }),
45
51
  querystring: z.object({
46
- role: z.enum(['execution', 'validation', 'planning']),
52
+ role: PipelineRoleSchema,
47
53
  task_id: z.coerce.number().int().positive().optional(),
48
54
  }),
49
55
  response: {
50
56
  200: ResolveModelResponseSchema,
57
+ 400: ErrorResponseSchema,
51
58
  404: ErrorResponseSchema,
52
59
  },
53
60
  },
54
61
  }, async (request, reply) => {
55
- // Existence guard → 404 on a missing project. getProject throws
56
- // NotFoundError, mapped to the 404 ProblemDetails by the error handler.
57
- fastify.projectService.getProject(request.params.id);
62
+ // Existence guard → 404 on a missing project: since task #928 the
63
+ // resolver itself throws NotFoundError (mapped to the 404
64
+ // ProblemDetails by the error handler), so the former
65
+ // `projectService.getProject` pre-fetch here was a redundant second
66
+ // fetch + full inflation of the same row (task #931 — one shared fetch).
58
67
  const resolved = fastify.modelPolicyService.resolveModel(request.params.id, request.query.role, request.query.task_id);
59
68
  // `resolved` is `{ model } | { model: 'auto' } | null` — sent verbatim so
60
69
  // the remote MCP tool is transport-indistinguishable from the stdio one.
@@ -104,6 +104,10 @@ const HealthFindingSchema = z.object({
104
104
  'stale-time-criticality',
105
105
  'high-fallback-ratio',
106
106
  'score-churn',
107
+ 'auto-sized-pending',
108
+ // Task #1004: emitted by check_health (edge-less blocked lint); part of
109
+ // the shared HealthCheckId union, never produced by the WSJF linter.
110
+ 'blocked-without-edge',
107
111
  ]),
108
112
  severity: z.enum(['info', 'warning', 'critical']),
109
113
  message: z.string(),
@@ -48,10 +48,14 @@ const modelPolicyRoutes = async (fastify) => {
48
48
  // the clear path persists SQL NULL rather than tripping the service's
49
49
  // non-null Zod parse. A non-null body has already passed the
50
50
  // `ModelPolicyNullableSchema` body validator (invalid → 400 upstream).
51
- fastify.settingsService.setModelPolicyDefault(request.body ?? null);
52
- // Echo the persisted policy back so callers get a post-write confirmation
53
- // (and round-trip the canonical stored shape).
54
- return reply.send(fastify.settingsService.getModelPolicyDefault());
51
+ const policy = request.body ?? null;
52
+ fastify.settingsService.setModelPolicyDefault(policy);
53
+ // Echo the validated body back. `request.body` has already been parsed
54
+ // through `ModelPolicyNullableSchema` by the route's body validator —
55
+ // the SAME canonical shape the service just persisted — so re-reading +
56
+ // re-validating the singleton row here was a redundant round-trip
57
+ // (task #931). A write failure throws before this line.
58
+ return reply.send(policy);
55
59
  });
56
60
  };
57
61
  export default modelPolicyRoutes;
@@ -31,6 +31,8 @@ export declare const TaskResponseSchema: z.ZodObject<{
31
31
  updated_at: z.ZodString;
32
32
  version: z.ZodNumber;
33
33
  claimed_at: z.ZodNullable<z.ZodString>;
34
+ claim_ttl_minutes: z.ZodOptional<z.ZodNumber>;
35
+ claim_remaining_seconds: z.ZodOptional<z.ZodNumber>;
34
36
  tags: z.ZodArray<z.ZodString>;
35
37
  acceptance_criteria: z.ZodNullable<z.ZodString>;
36
38
  verification_evidence: z.ZodNullable<z.ZodObject<{
@@ -88,6 +90,8 @@ export declare const TaskListResponseSchema: z.ZodArray<z.ZodObject<{
88
90
  updated_at: z.ZodString;
89
91
  version: z.ZodNumber;
90
92
  claimed_at: z.ZodNullable<z.ZodString>;
93
+ claim_ttl_minutes: z.ZodOptional<z.ZodNumber>;
94
+ claim_remaining_seconds: z.ZodOptional<z.ZodNumber>;
91
95
  tags: z.ZodArray<z.ZodString>;
92
96
  acceptance_criteria: z.ZodNullable<z.ZodString>;
93
97
  verification_evidence: z.ZodNullable<z.ZodObject<{
@@ -145,6 +149,8 @@ export declare const TaskListPaginatedResponseSchema: z.ZodObject<{
145
149
  updated_at: z.ZodString;
146
150
  version: z.ZodNumber;
147
151
  claimed_at: z.ZodNullable<z.ZodString>;
152
+ claim_ttl_minutes: z.ZodOptional<z.ZodNumber>;
153
+ claim_remaining_seconds: z.ZodOptional<z.ZodNumber>;
148
154
  tags: z.ZodArray<z.ZodString>;
149
155
  acceptance_criteria: z.ZodNullable<z.ZodString>;
150
156
  verification_evidence: z.ZodNullable<z.ZodObject<{
@@ -210,6 +216,8 @@ export declare const ClaimResponseSchema: z.ZodObject<{
210
216
  updated_at: z.ZodString;
211
217
  version: z.ZodNumber;
212
218
  claimed_at: z.ZodNullable<z.ZodString>;
219
+ claim_ttl_minutes: z.ZodOptional<z.ZodNumber>;
220
+ claim_remaining_seconds: z.ZodOptional<z.ZodNumber>;
213
221
  tags: z.ZodArray<z.ZodString>;
214
222
  acceptance_criteria: z.ZodNullable<z.ZodString>;
215
223
  verification_evidence: z.ZodNullable<z.ZodObject<{
@@ -21,6 +21,16 @@ export const TaskResponseSchema = z.object({
21
21
  updated_at: z.string(),
22
22
  version: z.number(),
23
23
  claimed_at: z.string().nullable(),
24
+ /**
25
+ * Task #1003: claim-TTL visibility. Computed at read time by
26
+ * `TaskService.getTask` for tasks holding an active claim (in_progress +
27
+ * assignee + claimed_at); absent otherwise. `claim_remaining_seconds` is
28
+ * the floor-clamped time until the ClaimReleaseService sweep may
29
+ * auto-release the claim — renew with a same-assignee `claim` call
30
+ * before it reaches 0.
31
+ */
32
+ claim_ttl_minutes: z.number().int().positive().optional(),
33
+ claim_remaining_seconds: z.number().int().nonnegative().optional(),
24
34
  tags: z.array(z.string()),
25
35
  /**
26
36
  * Wave 1.3 (task #311): optional free-form acceptance criteria (markdown).
@@ -9,7 +9,7 @@ import { TopologyService } from '../services/topology.service.js';
9
9
  import { CommentService } from '../services/comment.service.js';
10
10
  import { SettingsService } from '../services/settings.service.js';
11
11
  import { ModelCatalogService } from '../services/model-catalog.service.js';
12
- import { type ModelPolicyService } from '../services/model-policy.service.js';
12
+ import type { ModelPolicyService } from '../services/model-policy.service.js';
13
13
  import { SSEManager } from '../events/sse-manager.js';
14
14
  import { IdempotencyService } from '../services/idempotency.service.js';
15
15
  /**