@thehammer/danx-dashboard-mcp 0.1.214 → 0.1.215
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/dist/handlers.js +12 -0
- package/dist/http-client.js +67 -5
- package/dist/index.js +8 -2
- package/dist/session-connection.js +10 -0
- package/package.json +1 -1
- package/dist/route-board-scope.generated.js +0 -49
- package/dist/route-board-scope.js +0 -51
package/dist/handlers.js
CHANGED
|
@@ -52,6 +52,18 @@ const PLAN_SESSIONS_BASE_PATH = "/api/plan-sessions";
|
|
|
52
52
|
* only when the caller supplied one; the server leaves an unset title alone.
|
|
53
53
|
*/
|
|
54
54
|
export async function planConnect(client, args) {
|
|
55
|
+
if (args.disconnect === true) {
|
|
56
|
+
if (args.title !== undefined) {
|
|
57
|
+
return { ok: false, status: 400, body: { error: "plan_connect: `disconnect: true` takes no `title` — a leave changes no title" } };
|
|
58
|
+
}
|
|
59
|
+
// DX-2985 — `DELETE /api/plan-sessions/me/plan?plan_id=`: `me` is the forwarded session id, so only the caller's own session leaves.
|
|
60
|
+
return client.request({
|
|
61
|
+
method: "DELETE",
|
|
62
|
+
path: "/me/plan",
|
|
63
|
+
basePath: PLAN_SESSIONS_BASE_PATH,
|
|
64
|
+
query: { plan_id: args.plan_id },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
55
67
|
return client.request({
|
|
56
68
|
method: "POST",
|
|
57
69
|
path: "/me/plan",
|
package/dist/http-client.js
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
|
-
import { isBoardScopedRoute } from "./route-board-scope.js";
|
|
2
1
|
import { assertVersionFloorSatisfied, MCP_MIN_VERSION_HEADER } from "./version-floor.js";
|
|
2
|
+
/**
|
|
3
|
+
* DX-3750 — route-path prefixes (`<basePath><path>`, BEFORE any query
|
|
4
|
+
* string) that are NEVER board-scoped: the dashboard route serving them
|
|
5
|
+
* takes no `?board=` at all — either because the resource is GLOBAL
|
|
6
|
+
* (plans — see `plan_list`'s own doc: "Plans are GLOBAL, not board-scoped")
|
|
7
|
+
* or because it scopes some other way a `board` param would be meaningless
|
|
8
|
+
* for (dispatches scope by `repo`; a board resolves from a PATH segment on
|
|
9
|
+
* every route but its own list, which itself scopes by `repo`; `/api/routes`
|
|
10
|
+
* and `/api/openapi` are cross-board discovery). Server-side, each of these
|
|
11
|
+
* families' LIST route validates its query params with its own
|
|
12
|
+
* `rejectUnknownParams(params, TOP_LEVEL_..._PARAM_KEYS)` allowlist
|
|
13
|
+
* (`src/issues/plans-routes.ts`, `src/dashboard/dispatches-routes.ts`,
|
|
14
|
+
* `src/issues/boards-routes.ts`) — none of those allowlists names `board`
|
|
15
|
+
* (DX-3725), so stamping it 400s. THIS list and `NON_BOARD_SCOPED_PATH_PATTERNS` below are the only
|
|
16
|
+
* classification; `isBoardScopedRoute` is the one function that reads them — no second, independently-drifting
|
|
17
|
+
* copy anywhere else in this package.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately prefix-matched, not exact-matched: every sub-route of a
|
|
20
|
+
* non-board-scoped family (`/api/plans/:id/cards`, `/api/boards/:id/export`,
|
|
21
|
+
* `/api/dispatches/:id/logs`, …) is equally never board-scoped — a plan, a
|
|
22
|
+
* dispatch and a board-export all resolve their target from a PATH segment
|
|
23
|
+
* or a `repo`/global scope, never from `?board=`.
|
|
24
|
+
*/
|
|
25
|
+
const NON_BOARD_SCOPED_PATH_PREFIXES = [
|
|
26
|
+
"/api/plans",
|
|
27
|
+
"/api/dispatches",
|
|
28
|
+
"/api/boards",
|
|
29
|
+
"/api/routes",
|
|
30
|
+
"/api/openapi",
|
|
31
|
+
// DX-4261: install- or team-global families, whose list reads declare no `board` (the engine 400s an unknown key).
|
|
32
|
+
"/api/team",
|
|
33
|
+
"/api/users",
|
|
34
|
+
"/api/account",
|
|
35
|
+
"/api/platform",
|
|
36
|
+
"/api/failure-categories",
|
|
37
|
+
"/api/failure-signatures",
|
|
38
|
+
"/api/notifications",
|
|
39
|
+
"/api/self-repair",
|
|
40
|
+
"/api/audit-events",
|
|
41
|
+
"/api/docs",
|
|
42
|
+
"/api/repos",
|
|
43
|
+
"/api/sync-root",
|
|
44
|
+
"/api/workers",
|
|
45
|
+
"/api/tasks",
|
|
46
|
+
"/api/reminders",
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* DX-4261: non-board-scoped routes a prefix cannot express, because their family root (`/api/issues`, `/api/catalog`) is board-scoped.
|
|
50
|
+
* `/api/agents` stays stamped: `?board=` there selects the roster read (DX-4220).
|
|
51
|
+
*/
|
|
52
|
+
const NON_BOARD_SCOPED_PATH_PATTERNS = [
|
|
53
|
+
/^\/api\/issues\/[^/]+\/(dispatches|problems)$/,
|
|
54
|
+
/^\/api\/catalog\/[^/]+$/,
|
|
55
|
+
// The LIST read only: `/api/plan-sessions/me/plan` reads the stamped board to pin the operator dispatch (DX-2954).
|
|
56
|
+
/^\/api\/plan-sessions$/,
|
|
57
|
+
];
|
|
58
|
+
/** DX-3750 — does `routePath` (`<basePath><path>`, no query string) take a `?board=` at all? See `NON_BOARD_SCOPED_PATH_PREFIXES`'s own doc. */
|
|
59
|
+
export function isBoardScopedRoute(routePath) {
|
|
60
|
+
return !(NON_BOARD_SCOPED_PATH_PREFIXES.some((prefix) => routePath === prefix || routePath.startsWith(`${prefix}/`)) ||
|
|
61
|
+
NON_BOARD_SCOPED_PATH_PATTERNS.some((pattern) => pattern.test(routePath)));
|
|
62
|
+
}
|
|
3
63
|
export class DashboardHttpClient {
|
|
4
64
|
config;
|
|
5
65
|
fetchImpl;
|
|
@@ -11,7 +71,7 @@ export class DashboardHttpClient {
|
|
|
11
71
|
this.fetchImpl = fetchImpl;
|
|
12
72
|
}
|
|
13
73
|
async request(args) {
|
|
14
|
-
const url = this.buildUrl(args.
|
|
74
|
+
const url = this.buildUrl(args.path, args.query, args.board, args.basePath);
|
|
15
75
|
const headers = {
|
|
16
76
|
Authorization: `Bearer ${this.config.token}`,
|
|
17
77
|
Accept: "application/json",
|
|
@@ -87,7 +147,7 @@ export class DashboardHttpClient {
|
|
|
87
147
|
}
|
|
88
148
|
return { ok: false, status: res.status, body: parsed };
|
|
89
149
|
}
|
|
90
|
-
buildUrl(
|
|
150
|
+
buildUrl(path, extraQuery, boardOverride, basePath = "/api/issues") {
|
|
91
151
|
const base = this.config.baseUrl.replace(/\/+$/, "");
|
|
92
152
|
const [rawPath, existingQs] = path.split("?", 2);
|
|
93
153
|
let cleanPath;
|
|
@@ -98,8 +158,10 @@ export class DashboardHttpClient {
|
|
|
98
158
|
cleanPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
|
99
159
|
}
|
|
100
160
|
const params = new URLSearchParams(existingQs ?? "");
|
|
101
|
-
// DX-3750
|
|
102
|
-
|
|
161
|
+
// DX-3750 — only a board-scoped route takes `?board=` at all; see
|
|
162
|
+
// `isBoardScopedRoute`'s own doc for why an unconditional stamp 400s
|
|
163
|
+
// plans/dispatches/boards-list through `danxbot_api`.
|
|
164
|
+
if (isBoardScopedRoute(`${basePath}${cleanPath}`)) {
|
|
103
165
|
params.set("board", boardOverride ?? this.config.board);
|
|
104
166
|
}
|
|
105
167
|
if (extraQuery) {
|
package/dist/index.js
CHANGED
|
@@ -79,7 +79,7 @@ import { ACTIVITY_SUBCOMMAND, runActivityCommand } from "./activity.js";
|
|
|
79
79
|
import { EVENT_TEXT_SUBCOMMAND, runEventTextCommand } from "./event-text.js";
|
|
80
80
|
import { RESTART_NOTICE_SUBCOMMAND, runRestartNoticeCommand } from "./restart-notice.js";
|
|
81
81
|
import { resolveDeclaredCredential } from "./credential.js";
|
|
82
|
-
import { projectKeyOf, recordSessionConnectionAfterConnect } from "./session-connection.js";
|
|
82
|
+
import { projectKeyOf, recordSessionConnectionAfterConnect, removeSessionConnectionAfterLeave } from "./session-connection.js";
|
|
83
83
|
import { createListenerReminder, isPlanConnectedOnThisMachine, } from "./listener-reminder.js";
|
|
84
84
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
85
85
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -462,13 +462,14 @@ strictTool("attach_file", "Attach a LOCAL file to a card: `card_id` + `path` (an
|
|
|
462
462
|
// no existing plan for an id to name yet.
|
|
463
463
|
strictTool("plan_connect",
|
|
464
464
|
// DX-2735: trimmed with the problem tools to stay inside the work-profile budget.
|
|
465
|
-
"Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth, missedEvents}` — `missedEvents` is `{events, omitted}`: the operator events on this plan that no session had been told about before you connected, oldest first, capped to the newest few (`omitted` counts the older ones left out) — read them before anything else. `browserInstruction` is a server-built action to take NOW: it names the plan's URL and tells you to open it (in-app browser if you have one, else default), keep that tab open for the whole session without navigating it away, and use a different tab for your own browsing — the operator's tab for following and talking to you. Returned on every connect, including a re-connect, so it doubles as the post-context-loss reminder. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the plan read + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). Binds only your OWN session, resolved from the session id this server forwards; afterwards a `danxbot_api` call to a `/api/plans/mine/...` route (there is no dedicated plan-write tool) acts on this plan and takes no plan id. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape the plan list/get routes report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session unpolled, relayed by the plugin's event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<statement>\": chose \"Pause E2E\"`. An action the operator DECLINES arrives as `... rejected the action \"<title>\" for \"<statement>\" — reason: \"<note>\". Do not perform it; re-plan from the reason` (a decision with `outcome: \"rejected\"` — never treat it as work done). Pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server cannot read it itself. The response's `reminders[]` (when present) carries the mantra — read and follow it.", {
|
|
465
|
+
"Connect THIS session to a plan. ONE CALL IS ENOUGH TO START: the reply carries `{session, movedFrom, browserInstruction, briefing, listenerHealth, missedEvents}` — `missedEvents` is `{events, omitted}`: the operator events on this plan that no session had been told about before you connected, oldest first, capped to the newest few (`omitted` counts the older ones left out) — read them before anything else. `browserInstruction` is a server-built action to take NOW: it names the plan's URL and tells you to open it (in-app browser if you have one, else default), keep that tab open for the whole session without navigating it away, and use a different tab for your own browsing — the operator's tab for following and talking to you. Returned on every connect, including a re-connect, so it doubles as the post-context-loss reminder. `briefing` is every goal/rule/caveat (ref+body), every architecture section (id+title), the plan's own ref/name/status, a first page of open cards (id/type/status/title/openProblemCount/assignedAgent) with a `morePagesHint` when more exist, and the closed-card count — usually replacing the plan read + card batch-read a fresh session used to need. A session is on AT MOST ONE plan: connecting elsewhere MOVES it, and `movedFrom: {id, name} | null` says which plan it left (null = no plan, or already this one). Binds only your OWN session, resolved from the session id this server forwards; afterwards a `danxbot_api` call to a `/api/plans/mine/...` route (there is no dedicated plan-write tool) acts on this plan and takes no plan id. `listenerHealth` reports the event bridge's state — `null` (no session), or `{attached, state: \"unattached\"|\"credential_mismatch\"|\"healthy\", nextStep}` naming a concrete fix per unhealthy state (same shape the plan list/get routes report). Once healthy, every comment, answer, problem added and block/unblock on this plan's cards reaches the session unpolled, relayed by the plugin's event bridge as a line like `[DX-8 \"Title\" repo:board] newms87 answered \"<statement>\": chose \"Pause E2E\"`. An action the operator DECLINES arrives as `... rejected the action \"<title>\" for \"<statement>\" — reason: \"<note>\". Do not perform it; re-plan from the reason` (a decision with `outcome: \"rejected\"` — never treat it as work done). Pass `title` (call `get_session({session_id:\"self\"})` first and forward its `title` verbatim) so the dashboard shows the same name Claude does — this server cannot read it itself. The response's `reminders[]` (when present) carries the mantra — read and follow it. To LEAVE: `{plan_id: <current plan>, disconnect: true}` returns `{session, leftPlan}`; a wrong plan is a 409.", {
|
|
466
466
|
plan_id: z.number().int().positive().describe("The plan id, from the plans list (`danxbot_api` `GET /api/plans`)."),
|
|
467
467
|
title: z
|
|
468
468
|
.string()
|
|
469
469
|
.min(1)
|
|
470
470
|
.optional()
|
|
471
471
|
.describe("THIS session's own Claude session title — from `get_session({session_id:\"self\"}).title`, passed verbatim, never invented or derived from the repo/cwd. Stored on your session's row: a title different from what is already stored UPDATES it; omit to leave the stored title untouched. Not this plan's name. At most 200 characters — an overlong title is refused with a 400 naming its length."),
|
|
472
|
+
disconnect: z.boolean().optional().describe("true = leave `plan_id` (your current plan); no `title`."),
|
|
472
473
|
},
|
|
473
474
|
// DX-2862 — the connect is also the moment this session's event bridge learns
|
|
474
475
|
// WHICH dashboard credential owns its stream: of the dashboard MCP servers a
|
|
@@ -478,6 +479,11 @@ async (args) => {
|
|
|
478
479
|
const result = await planConnect(client, args);
|
|
479
480
|
// DX-3997 — a (re)connect restarts the bridge; the next `danxbot_api` must re-read health.
|
|
480
481
|
listenerReminder.invalidate();
|
|
482
|
+
// DX-2985 — a leave drops the local connection record (the bridge exits on `not_connected`; a later connect writes a fresh one).
|
|
483
|
+
if (args.disconnect === true) {
|
|
484
|
+
removeSessionConnectionAfterLeave(result, config.session?.id);
|
|
485
|
+
return jsonResult(result);
|
|
486
|
+
}
|
|
481
487
|
return jsonResult({
|
|
482
488
|
...result,
|
|
483
489
|
...recordSessionConnectionAfterConnect(result, {
|
|
@@ -170,6 +170,16 @@ export function recordSessionConnectionAfterConnect(result, inputs, write = writ
|
|
|
170
170
|
};
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* DX-2985 — drop this session's local connection record after a successful leave (`plan_connect {disconnect: true}`), so
|
|
175
|
+
* nothing on this machine still treats the session as plan-connected (the listener-reminder reads this record). A refused
|
|
176
|
+
* leave keeps it: the session is still on its plan. Never throws for an absent record.
|
|
177
|
+
*/
|
|
178
|
+
export function removeSessionConnectionAfterLeave(result, sessionId, home = homedir()) {
|
|
179
|
+
if (!result.ok || sessionId === undefined || sessionId === "")
|
|
180
|
+
return;
|
|
181
|
+
rmSync(sessionConnectionPath(sessionId, home), { force: true });
|
|
182
|
+
}
|
|
173
183
|
/** Why a parsed record cannot be used, or `null` when it can. */
|
|
174
184
|
export function invalidRecordReason(value) {
|
|
175
185
|
if (typeof value !== "object" || value === null)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thehammer/danx-dashboard-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.215",
|
|
4
4
|
"description": "Stdio MCP server fronting danxbot's dashboard API for agents (operator sessions and dispatched workers alike): the generic danxbot_api/danxbot_api_list/danxbot_api_spec tools reach any route the caller's credential may call, plus dedicated agent-specific tools (plan_connect, danxbot_complete, and other per-dispatch lifecycle callbacks) (DX-3465, PLN-11 R-25).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GENERATED by src/dashboard/mcp-board-scope.ts (DX-4303) - do not edit by hand.
|
|
3
|
-
* Regenerate: DANX_REGEN_MCP_BOARD_SCOPE=1 npx vitest run src/dashboard/mcp-board-scope.test.ts
|
|
4
|
-
*
|
|
5
|
-
* The GET /api/* route templates the client must know to stamp `?board=` correctly, and whether each takes it. The server refuses an
|
|
6
|
-
* unknown key on a list read whose vocabulary omits `board` and on a route declaring `strictQuery`; a path no template names is stamped.
|
|
7
|
-
*/
|
|
8
|
-
export const GET_ROUTE_BOARD_SCOPE = [
|
|
9
|
-
["/api/account/sessions", false],
|
|
10
|
-
["/api/audit-events", false],
|
|
11
|
-
["/api/boards", false],
|
|
12
|
-
["/api/boards/:boardId", false],
|
|
13
|
-
["/api/catalog/:catalogId", false],
|
|
14
|
-
["/api/dispatches", false],
|
|
15
|
-
["/api/dispatches/:dispatchId", true],
|
|
16
|
-
["/api/dispatches/:dispatchId/logs", false],
|
|
17
|
-
["/api/dispatches/:dispatchId/usage-events", false],
|
|
18
|
-
["/api/dispatches/dead-letter-ids", false],
|
|
19
|
-
["/api/docs/:repo/changes", false],
|
|
20
|
-
["/api/failure-categories", false],
|
|
21
|
-
["/api/failure-categories/ambiguous", false],
|
|
22
|
-
["/api/failure-categories/uncategorized", false],
|
|
23
|
-
["/api/failure-signatures", false],
|
|
24
|
-
["/api/issues/:id/problems", false],
|
|
25
|
-
["/api/issues/:id/usage-runs", false],
|
|
26
|
-
["/api/issues/:id/usage-series", false],
|
|
27
|
-
["/api/issues/:issueId/dispatches", false],
|
|
28
|
-
["/api/notifications", false],
|
|
29
|
-
["/api/plan-sessions", false],
|
|
30
|
-
["/api/plans", false],
|
|
31
|
-
["/api/plans/:planId/architecture/sections", false],
|
|
32
|
-
["/api/plans/:planId/cards", false],
|
|
33
|
-
["/api/plans/:planId/notes", false],
|
|
34
|
-
["/api/plans/:planId/records", false],
|
|
35
|
-
["/api/plans/:planId/usage-series", false],
|
|
36
|
-
["/api/plans/mine/architecture/sections", false],
|
|
37
|
-
["/api/platform/signup-codes", false],
|
|
38
|
-
["/api/platform/teams", false],
|
|
39
|
-
["/api/reminders", false],
|
|
40
|
-
["/api/repos", false],
|
|
41
|
-
["/api/self-repair/errors", false],
|
|
42
|
-
["/api/sync-root", false],
|
|
43
|
-
["/api/tasks", false],
|
|
44
|
-
["/api/team/invitations", false],
|
|
45
|
-
["/api/team/members", false],
|
|
46
|
-
["/api/team/roles", false],
|
|
47
|
-
["/api/users", false],
|
|
48
|
-
["/api/workers", false],
|
|
49
|
-
];
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DX-4303 - does the dashboard route a request reaches take a `?board=` query key?
|
|
3
|
-
*
|
|
4
|
-
* The answer comes from the dashboard's own route table, shipped in this package as `route-board-scope.generated.ts` (generated by
|
|
5
|
-
* the server's `src/dashboard/mcp-board-scope.ts`; a root test fails when it drifts). This file only matches a concrete request
|
|
6
|
-
* path against those templates, the way the server's `RouteTable` does: segments compared left to right, a literal beating a
|
|
7
|
-
* `:param` beating a trailing `*`. It holds no route knowledge of its own.
|
|
8
|
-
*
|
|
9
|
-
* Only GET routes are listed - the server refuses an unknown query key on a read alone - so every other method takes the key. A
|
|
10
|
-
* GET path no template matches takes it too: the server answers it with a 404 whatever the query.
|
|
11
|
-
*/
|
|
12
|
-
import { GET_ROUTE_BOARD_SCOPE } from "./route-board-scope.generated.js";
|
|
13
|
-
const RANK = { literal: 2, param: 1, rest: 0 };
|
|
14
|
-
function compile(template, acceptsBoard) {
|
|
15
|
-
const segments = template
|
|
16
|
-
.slice(1)
|
|
17
|
-
.split("/")
|
|
18
|
-
.map((s) => (s === "*" ? { kind: "rest" } : s.startsWith(":") ? { kind: "param" } : { kind: "literal", value: s }));
|
|
19
|
-
return { segments, rank: segments.map((s) => RANK[s.kind]), acceptsBoard };
|
|
20
|
-
}
|
|
21
|
-
function compareRank(a, b) {
|
|
22
|
-
const n = Math.max(a.length, b.length);
|
|
23
|
-
for (let i = 0; i < n; i++) {
|
|
24
|
-
const x = a[i] ?? -1;
|
|
25
|
-
const y = b[i] ?? -1;
|
|
26
|
-
if (x !== y)
|
|
27
|
-
return y - x;
|
|
28
|
-
}
|
|
29
|
-
return 0;
|
|
30
|
-
}
|
|
31
|
-
const ROUTES = GET_ROUTE_BOARD_SCOPE.map(([template, accepts]) => compile(template, accepts)).sort((a, b) => compareRank(a.rank, b.rank));
|
|
32
|
-
function matches(route, parts) {
|
|
33
|
-
for (let i = 0; i < route.segments.length; i++) {
|
|
34
|
-
const seg = route.segments[i];
|
|
35
|
-
if (seg.kind === "rest")
|
|
36
|
-
return true;
|
|
37
|
-
const part = parts[i];
|
|
38
|
-
if (part === undefined)
|
|
39
|
-
return false;
|
|
40
|
-
if (seg.kind === "literal" ? part !== seg.value : part === "")
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
return parts.length === route.segments.length;
|
|
44
|
-
}
|
|
45
|
-
/** Does the dashboard route `<method> <routePath>` (no query string) take a `?board=` query key? */
|
|
46
|
-
export function isBoardScopedRoute(method, routePath) {
|
|
47
|
-
if (method !== "GET")
|
|
48
|
-
return true;
|
|
49
|
-
const parts = routePath.slice(1).split("/");
|
|
50
|
-
return ROUTES.find((route) => matches(route, parts))?.acceptsBoard ?? true;
|
|
51
|
-
}
|