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.
- package/AGENTS.md +10 -0
- package/CHANGELOG.md +97 -3
- package/README.md +2 -1
- package/SECURITY.md +3 -3
- package/dist/api/api-response.d.ts +4 -0
- package/dist/api/routes/auth/callback.js +11 -1
- package/dist/api/routes/auth/device-code.d.ts +17 -1
- package/dist/api/routes/auth/device-code.js +26 -3
- package/dist/api/routes/auth/device-html.js +11 -1
- package/dist/api/routes/auth/device-token.js +14 -1
- package/dist/api/routes/auth/login.js +13 -1
- package/dist/api/routes/models/index.d.ts +16 -0
- package/dist/api/routes/models/index.js +1 -7
- package/dist/api/routes/projects/resolve-model.js +17 -8
- package/dist/api/routes/projects/wsjf.js +4 -0
- package/dist/api/routes/settings/model-policy.js +8 -4
- package/dist/api/routes/tasks/schemas.d.ts +8 -0
- package/dist/api/routes/tasks/schemas.js +10 -0
- package/dist/api/server.d.ts +1 -1
- package/dist/api/server.js +40 -21
- package/dist/cli/api/client.d.ts +7 -7
- package/dist/cli/api/types.d.ts +6 -0
- package/dist/cli/auth/browser-open.js +21 -15
- package/dist/cli/auth/credentials.d.ts +19 -0
- package/dist/cli/auth/credentials.js +30 -0
- package/dist/cli/commands/comment-add.js +8 -1
- package/dist/cli/commands/create.js +9 -1
- package/dist/cli/commands/list.js +30 -7
- package/dist/cli/commands/login.d.ts +10 -0
- package/dist/cli/commands/login.js +2 -1
- package/dist/cli/commands/models.d.ts +38 -3
- package/dist/cli/commands/models.js +48 -4
- package/dist/cli/commands/project-set-models.js +6 -19
- package/dist/cli/commands/settings-set-models.js +6 -19
- package/dist/cli/commands/setup.d.ts +8 -0
- package/dist/cli/commands/setup.js +1 -0
- package/dist/cli/commands/update.js +20 -0
- package/dist/config/env.d.ts +7 -0
- package/dist/config/env.js +63 -0
- package/dist/events/event-bus.d.ts +2 -1
- package/dist/events/event-bus.js +1 -0
- package/dist/events/sse-manager.d.ts +30 -1
- package/dist/events/sse-manager.js +100 -18
- package/dist/events/types.d.ts +19 -2
- package/dist/events/types.js +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +36 -1
- package/dist/mcp/index.js +5 -24
- package/dist/mcp/lib/model-tool-definitions.d.ts +122 -0
- package/dist/mcp/lib/model-tool-definitions.js +112 -0
- package/dist/mcp/remote/register-tools.js +14 -73
- package/dist/mcp/remote/rest-client.d.ts +4 -10
- package/dist/mcp/resources/events.js +2 -1
- package/dist/mcp/tools/health-tools.d.ts +14 -0
- package/dist/mcp/tools/health-tools.js +52 -1
- package/dist/mcp/tools/model-tools.d.ts +4 -3
- package/dist/mcp/tools/model-tools.js +13 -82
- package/dist/mcp/tools/task-tools.js +5 -1
- package/dist/repositories/interfaces.d.ts +39 -0
- package/dist/repositories/project-charter-history.repository.js +2 -12
- package/dist/repositories/project.repository.js +16 -48
- package/dist/repositories/task.repository.d.ts +48 -0
- package/dist/repositories/task.repository.js +90 -18
- package/dist/repositories/wsjf-history.repository.js +6 -16
- package/dist/schemas/model-catalog.schema.d.ts +22 -0
- package/dist/schemas/model-catalog.schema.js +21 -0
- package/dist/schemas/model-policy.schema.d.ts +14 -0
- package/dist/schemas/model-policy.schema.js +11 -0
- package/dist/schemas/task.schema.d.ts +13 -1
- package/dist/schemas/task.schema.js +11 -0
- package/dist/services/claim-release.service.d.ts +11 -0
- package/dist/services/claim-release.service.js +31 -2
- package/dist/services/job-size-backfill.d.ts +43 -0
- package/dist/services/job-size-backfill.js +81 -0
- package/dist/services/model-catalog.service.d.ts +28 -8
- package/dist/services/model-catalog.service.js +16 -4
- package/dist/services/model-policy.service.d.ts +87 -9
- package/dist/services/model-policy.service.js +96 -4
- package/dist/services/settings.service.d.ts +3 -2
- package/dist/services/settings.service.js +30 -10
- package/dist/services/task.service.d.ts +87 -3
- package/dist/services/task.service.js +351 -16
- package/dist/services/wsjf-health.service.d.ts +7 -1
- package/dist/services/wsjf-health.service.js +27 -0
- package/dist/services/wsjf.service.d.ts +19 -0
- package/dist/services/wsjf.service.js +33 -0
- package/dist/skills/tasks/blocked.md +2 -1
- package/dist/skills/tasks/decompose.md +30 -4
- package/dist/skills/tasks/loop-dag.md +3 -1
- package/dist/skills/tasks/loop-shared.md +8 -0
- package/dist/skills/tasks/loop.md +1 -1
- package/dist/skills/tasks/update.md +8 -0
- package/dist/slack/commands/tasks-command.js +11 -7
- package/dist/slack/formatters/project-formatter.js +7 -4
- package/dist/slack/mrkdwn.d.ts +1 -0
- package/dist/slack/mrkdwn.js +26 -0
- package/dist/slack/notifier.js +1 -0
- package/dist/slack/task-formatter.js +13 -11
- package/dist/types/task.d.ts +12 -17
- package/dist/types/wsjf.d.ts +1 -1
- package/dist/types/wsjf.js +2 -0
- package/dist/utils/parse-json-column.d.ts +25 -0
- package/dist/utils/parse-json-column.js +39 -0
- package/docs/API.md +23 -10
- package/docs/ARCHITECTURE.md +16 -14
- package/docs/CLI.md +11 -12
- package/docs/INTERFACES.md +9 -2
- package/docs/MCP.md +105 -4
- package/docs/SETUP.md +39 -27
- package/docs/SLACK.md +1 -0
- package/package.json +5 -5
- package/packages/wft-router/README.md +14 -0
- package/packages/wft-router/dist/bin/wft-router.d.ts +10 -0
- package/packages/wft-router/dist/bin/wft-router.js +18 -0
- package/packages/wft-router/dist/config/event-types.d.ts +1 -1
- package/packages/wft-router/dist/config/event-types.js +1 -0
- package/packages/wft-router/dist/config/triggers-schema.d.ts +13 -0
- package/packages/wft-router/dist/config/triggers-schema.js +82 -0
- package/packages/wft-router/dist/daemon.d.ts +88 -0
- package/packages/wft-router/dist/daemon.js +175 -3
- package/packages/wft-router/dist/dispatch/startup-sweep.d.ts +96 -0
- package/packages/wft-router/dist/dispatch/startup-sweep.js +163 -0
- package/packages/wft-router/dist/index.d.ts +2 -2
- package/packages/wft-router/dist/index.js +1 -1
- package/packages/wft-router/dist/metrics.d.ts +64 -4
- package/packages/wft-router/dist/metrics.js +0 -0
- package/packages/wft-router/dist/sse/client.d.ts +19 -0
- package/packages/wft-router/dist/sse/client.js +57 -3
- package/packages/wft-router/dist/sse/index.d.ts +1 -1
- 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
|
-
|
|
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. `
|
|
69
|
-
`
|
|
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.
|
|
18
|
-
| `v1.0` – `v2.0
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
25
|
-
* ProblemDetails (mirrors the sibling
|
|
26
|
-
* routes)
|
|
27
|
-
* default (or null) silently
|
|
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:
|
|
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
|
|
56
|
-
// NotFoundError
|
|
57
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
|
|
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).
|
package/dist/api/server.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
/**
|