dbbe9f77f9
## Summary Major expansion of the MCP server (14 → 25 tools), context efficiency optimizations, new API endpoints, and a fix for the dev CLI leaking build directories on disk. ### New MCP tools - **Query & analytics**: `get_query_schema`, `query`, `list_dashboards`, `run_dashboard_query` — query your data using TRQL directly from AI assistants - **Profile management**: `whoami`, `list_profiles`, `switch_profile` — see and switch CLI profiles per-project (persisted to `.trigger/mcp.json`) - **Dev server control**: `start_dev_server`, `stop_dev_server`, `dev_server_status` — start/stop `trigger dev` and stream build output - **Task introspection**: `get_task_schema` — get payload schema for a specific task (split out from `get_current_worker` to reduce context) ### New API endpoints - `GET /api/v1/query/schema` — discover TRQL tables and columns (server-driven, multi-table) - `GET /api/v1/query/dashboards` — list built-in dashboard widgets and their queries ### New features - **`--readonly` flag** — hides write tools (`deploy`, `trigger_task`, `cancel_run`) so agents can't make changes - **`read:query` JWT scope** — new authorization scope for query endpoints, with per-table granularity (`read:query:runs`, `read:query:llm_metrics`, etc.) - **Paginated trace output** — `get_run_details` now paginates trace events via cursor, caching the full trace in a temp file so subsequent pages don't re-fetch - **MCP tool annotations** — all tools now have `readOnlyHint`/`destructiveHint` annotations for clients that support them - **Project-scoped profile persistence** — `switch_profile` saves to `.trigger/mcp.json` (gitignored), automatically loaded on next MCP server start ### Context optimizations - `get_query_schema` requires a table name — returns one table's schema instead of all tables (60-80% fewer tokens) - `get_current_worker` no longer inlines payload schemas — use `get_task_schema` for specific tasks - Query results formatted as text tables instead of JSON (~50% fewer tokens for flat data) - `cancel_run`, `list_deploys`, `list_preview_branches` formatted as text instead of raw `JSON.stringify()` - Schema and dashboard API responses cached (1hr and 5min respectively) ### Bug fixes - Fixed `search_docs` failing due to renamed upstream Mintlify tool (`SearchTriggerDev` → `search_trigger_dev`) - Fixed `list_deploys` failing when deployments have null `runtime`/`runtimeVersion` fields (fixes #3139) - Fixed `list_preview_branches` crashing due to incorrect response shape access - Fixed `metrics` table column documented as `value` instead of `metric_value` in query docs - Fixed `/api/v1/query` not accepting JWT auth (added `allowJWT: true`) ### Dev CLI build directory fix The dev CLI was leaking `build-*` directories in `.trigger/tmp/` on every rebuild, accumulating hundreds of MB over time (842MB observed). Three layers of protection added: 1. **During session**: deprecated workers are pruned (capped at 2 retained) when no active runs reference them, preventing unbounded accumulation 2. **On SIGKILL/crash**: the watchdog process now cleans up `.trigger/tmp/` when it detects the parent CLI was killed 3. **On next startup**: existing `clearTmpDirs()` wipes any remaining orphans ## Test plan - [ ] `pnpm run mcp:smoke` — 17 automated smoke tests for all read-only MCP tools - [ ] `pnpm run mcp:test list` — verify 25 tools registered (21 in `--readonly` mode) - [ ] `pnpm run mcp:test --readonly list` — verify write tools hidden - [ ] Manual: start dev server, trigger task, rebuild multiple times, verify build dirs stay capped at 4 - [ ] Manual: SIGKILL the dev CLI, verify watchdog cleans up `.trigger/tmp/` - [ ] Verify new API endpoints return correct data: `GET /api/v1/query/schema`, `GET /api/v1/query/dashboards` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { checkAuthorization, AuthorizationEntity } from "../app/services/authorization.server";
|
|
|
|
describe("checkAuthorization", () => {
|
|
// Test entities
|
|
const privateEntity: AuthorizationEntity = { type: "PRIVATE" };
|
|
const publicEntity: AuthorizationEntity = { type: "PUBLIC" };
|
|
const publicJwtEntityWithPermissions: AuthorizationEntity = {
|
|
type: "PUBLIC_JWT",
|
|
scopes: ["read:runs:run_1234", "read:tasks", "read:tags:tag_5678"],
|
|
};
|
|
const publicJwtEntityNoPermissions: AuthorizationEntity = { type: "PUBLIC_JWT" };
|
|
const publicJwtEntityWithTaskWritePermissions: AuthorizationEntity = {
|
|
type: "PUBLIC_JWT",
|
|
scopes: ["write:tasks:task-1"],
|
|
};
|
|
|
|
describe("PRIVATE entity", () => {
|
|
it("should always return authorized regardless of action or resource", () => {
|
|
const result1 = checkAuthorization(privateEntity, "read", { runs: "run_1234" });
|
|
expect(result1.authorized).toBe(true);
|
|
expect(result1).not.toHaveProperty("reason");
|
|
|
|
const result2 = checkAuthorization(privateEntity, "read", { tasks: ["task_1", "task_2"] });
|
|
expect(result2.authorized).toBe(true);
|
|
expect(result2).not.toHaveProperty("reason");
|
|
|
|
const result3 = checkAuthorization(privateEntity, "read", { tags: "nonexistent_tag" });
|
|
expect(result3.authorized).toBe(true);
|
|
expect(result3).not.toHaveProperty("reason");
|
|
});
|
|
});
|
|
|
|
describe("PUBLIC entity", () => {
|
|
it("should always return unauthorized with reason regardless of action or resource", () => {
|
|
const result1 = checkAuthorization(publicEntity, "read", { runs: "run_1234" });
|
|
expect(result1.authorized).toBe(false);
|
|
if (!result1.authorized) {
|
|
expect(result1.reason).toBe("PUBLIC type is deprecated and has no access");
|
|
}
|
|
|
|
const result2 = checkAuthorization(publicEntity, "read", { tasks: ["task_1", "task_2"] });
|
|
expect(result2.authorized).toBe(false);
|
|
if (!result2.authorized) {
|
|
expect(result2.reason).toBe("PUBLIC type is deprecated and has no access");
|
|
}
|
|
|
|
const result3 = checkAuthorization(publicEntity, "read", { tags: "tag_5678" });
|
|
expect(result3.authorized).toBe(false);
|
|
if (!result3.authorized) {
|
|
expect(result3.reason).toBe("PUBLIC type is deprecated and has no access");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("PUBLIC_JWT entity with task write scope", () => {
|
|
it("should return authorized for specific resource scope", () => {
|
|
const result = checkAuthorization(publicJwtEntityWithTaskWritePermissions, "write", {
|
|
tasks: "task-1",
|
|
});
|
|
expect(result.authorized).toBe(true);
|
|
expect(result).not.toHaveProperty("reason");
|
|
});
|
|
|
|
it("should return unauthorized with reason for unauthorized specific resources", () => {
|
|
const result = checkAuthorization(publicJwtEntityWithTaskWritePermissions, "write", {
|
|
tasks: "task-2",
|
|
});
|
|
expect(result.authorized).toBe(false);
|
|
if (!result.authorized) {
|
|
expect(result.reason).toBe(
|
|
"Public Access Token is missing required permissions. Token has the following permissions: 'write:tasks:task-1'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("PUBLIC_JWT entity with scope", () => {
|
|
it("should return authorized for specific resource scope", () => {
|
|
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
|
runs: "run_1234",
|
|
});
|
|
expect(result.authorized).toBe(true);
|
|
expect(result).not.toHaveProperty("reason");
|
|
});
|
|
|
|
it("should return unauthorized with reason for unauthorized specific resources", () => {
|
|
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
|
runs: "run_5678",
|
|
});
|
|
expect(result.authorized).toBe(false);
|
|
if (!result.authorized) {
|
|
expect(result.reason).toBe(
|
|
"Public Access Token is missing required permissions. Token has the following permissions: 'read:runs:run_1234', 'read:tasks', 'read:tags:tag_5678'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
|
);
|
|
}
|
|
});
|
|
|
|
it("should return authorized for general resource type scope", () => {
|
|
const result1 = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
|
tasks: "task_1234",
|
|
});
|
|
expect(result1.authorized).toBe(true);
|
|
expect(result1).not.toHaveProperty("reason");
|
|
|
|
const result2 = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
|
tasks: ["task_5678", "task_9012"],
|
|
});
|
|
expect(result2.authorized).toBe(true);
|
|
expect(result2).not.toHaveProperty("reason");
|
|
});
|
|
|
|
it("should return authorized if any resource in an array is authorized", () => {
|
|
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
|
tags: ["tag_1234", "tag_5678"],
|
|
});
|
|
expect(result.authorized).toBe(true);
|
|
expect(result).not.toHaveProperty("reason");
|
|
});
|
|
|
|
it("should return authorized for nonexistent resource types", () => {
|
|
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
|
// @ts-expect-error
|
|
nonexistent: "resource",
|
|
});
|
|
expect(result.authorized).toBe(false);
|
|
expect(result).toHaveProperty("reason");
|
|
});
|
|
});
|
|
|
|
describe("PUBLIC_JWT entity without scope", () => {
|
|
it("should always return unauthorized with reason regardless of action or resource", () => {
|
|
const result1 = checkAuthorization(publicJwtEntityNoPermissions, "read", {
|
|
runs: "run_1234",
|
|
});
|
|
expect(result1.authorized).toBe(false);
|
|
if (!result1.authorized) {
|
|
expect(result1.reason).toBe(
|
|
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
|
);
|
|
}
|
|
|
|
const result2 = checkAuthorization(publicJwtEntityNoPermissions, "read", {
|
|
tasks: ["task_1", "task_2"],
|
|
});
|
|
expect(result2.authorized).toBe(false);
|
|
if (!result2.authorized) {
|
|
expect(result2.reason).toBe(
|
|
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
|
);
|
|
}
|
|
|
|
const result3 = checkAuthorization(publicJwtEntityNoPermissions, "read", {
|
|
tags: "tag_5678",
|
|
});
|
|
expect(result3.authorized).toBe(false);
|
|
if (!result3.authorized) {
|
|
expect(result3.reason).toBe(
|
|
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Edge cases", () => {
|
|
it("should handle empty resource objects", () => {
|
|
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {});
|
|
expect(result.authorized).toBe(false);
|
|
if (!result.authorized) {
|
|
expect(result.reason).toBe("Resource object is empty");
|
|
}
|
|
});
|
|
|
|
it("should handle undefined scope", () => {
|
|
const entityUndefinedPermissions: AuthorizationEntity = { type: "PUBLIC_JWT" };
|
|
const result = checkAuthorization(entityUndefinedPermissions, "read", { runs: "run_1234" });
|
|
expect(result.authorized).toBe(false);
|
|
if (!result.authorized) {
|
|
expect(result.reason).toBe(
|
|
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
|
);
|
|
}
|
|
});
|
|
|
|
it("should handle empty scope array", () => {
|
|
const entityEmptyPermissions: AuthorizationEntity = { type: "PUBLIC_JWT", scopes: [] };
|
|
const result = checkAuthorization(entityEmptyPermissions, "read", { runs: "run_1234" });
|
|
expect(result.authorized).toBe(false);
|
|
if (!result.authorized) {
|
|
expect(result.reason).toBe(
|
|
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
|
);
|
|
}
|
|
});
|
|
|
|
it("should return authorized if any resource is authorized", () => {
|
|
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
|
runs: "run_1234", // This is authorized
|
|
tasks: "task_5678", // This is authorized (general permission)
|
|
tags: "tag_3456", // This is not authorized
|
|
});
|
|
expect(result.authorized).toBe(true);
|
|
expect(result).not.toHaveProperty("reason");
|
|
});
|
|
|
|
it("should return unauthorized only if no resources are authorized", () => {
|
|
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
|
runs: "run_5678", // Not authorized
|
|
tags: "tag_3456", // Not authorized
|
|
});
|
|
expect(result.authorized).toBe(false);
|
|
if (!result.authorized) {
|
|
expect(result.reason).toContain("Public Access Token is missing required permissions");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Super scope", () => {
|
|
const entityWithSuperPermissions: AuthorizationEntity = {
|
|
type: "PUBLIC_JWT",
|
|
scopes: ["read:all", "admin"],
|
|
};
|
|
|
|
const entityWithOneSuperPermission: AuthorizationEntity = {
|
|
type: "PUBLIC_JWT",
|
|
scopes: ["read:all"],
|
|
};
|
|
|
|
it("should grant access with any of the super scope", () => {
|
|
const result1 = checkAuthorization(
|
|
entityWithSuperPermissions,
|
|
"read",
|
|
{ tasks: "task_1234" },
|
|
["read:all", "admin"]
|
|
);
|
|
expect(result1.authorized).toBe(true);
|
|
expect(result1).not.toHaveProperty("reason");
|
|
|
|
const result2 = checkAuthorization(
|
|
entityWithSuperPermissions,
|
|
"read",
|
|
{ tags: ["tag_1", "tag_2"] },
|
|
["write:all", "admin"]
|
|
);
|
|
expect(result2.authorized).toBe(true);
|
|
expect(result2).not.toHaveProperty("reason");
|
|
});
|
|
|
|
it("should grant access with one matching super permission", () => {
|
|
const result = checkAuthorization(
|
|
entityWithOneSuperPermission,
|
|
"read",
|
|
{ runs: "run_5678" },
|
|
["read:all", "admin"]
|
|
);
|
|
expect(result.authorized).toBe(true);
|
|
expect(result).not.toHaveProperty("reason");
|
|
});
|
|
|
|
it("should not grant access when no super scope match", () => {
|
|
const result = checkAuthorization(
|
|
entityWithOneSuperPermission,
|
|
"read",
|
|
{ tasks: "task_1234" },
|
|
["write:all", "admin"]
|
|
);
|
|
expect(result.authorized).toBe(false);
|
|
if (!result.authorized) {
|
|
expect(result.reason).toBe(
|
|
"Public Access Token is missing required permissions. Token has the following permissions: 'read:all'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
|
);
|
|
}
|
|
});
|
|
|
|
it("should grant access to multiple resources with super scope", () => {
|
|
const result = checkAuthorization(
|
|
entityWithSuperPermissions,
|
|
"read",
|
|
{
|
|
tasks: "task_1234",
|
|
tags: ["tag_1", "tag_2"],
|
|
runs: "run_5678",
|
|
},
|
|
["read:all"]
|
|
);
|
|
expect(result.authorized).toBe(true);
|
|
expect(result).not.toHaveProperty("reason");
|
|
});
|
|
|
|
it("should fall back to specific scope when super scope are not provided", () => {
|
|
const entityWithSpecificPermissions: AuthorizationEntity = {
|
|
type: "PUBLIC_JWT",
|
|
scopes: ["read:tasks", "read:tags"],
|
|
};
|
|
const result1 = checkAuthorization(entityWithSpecificPermissions, "read", {
|
|
tasks: "task_1234",
|
|
});
|
|
expect(result1.authorized).toBe(true);
|
|
expect(result1).not.toHaveProperty("reason");
|
|
|
|
const result2 = checkAuthorization(entityWithSpecificPermissions, "read", {
|
|
runs: "run_5678",
|
|
});
|
|
expect(result2.authorized).toBe(false);
|
|
if (!result2.authorized) {
|
|
expect(result2.reason).toBe(
|
|
"Public Access Token is missing required permissions. Token has the following permissions: 'read:tasks', 'read:tags'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Query resource type", () => {
|
|
it("should grant access with read:query super scope", () => {
|
|
const entity: AuthorizationEntity = {
|
|
type: "PUBLIC_JWT",
|
|
scopes: ["read:query"],
|
|
};
|
|
const result = checkAuthorization(
|
|
entity,
|
|
"read",
|
|
{ query: "runs" },
|
|
["read:query", "read:all", "admin"]
|
|
);
|
|
expect(result.authorized).toBe(true);
|
|
});
|
|
|
|
it("should grant access with table-specific query scope", () => {
|
|
const entity: AuthorizationEntity = {
|
|
type: "PUBLIC_JWT",
|
|
scopes: ["read:query:runs"],
|
|
};
|
|
const result = checkAuthorization(entity, "read", { query: "runs" });
|
|
expect(result.authorized).toBe(true);
|
|
});
|
|
|
|
it("should deny access to different table with table-specific scope", () => {
|
|
const entity: AuthorizationEntity = {
|
|
type: "PUBLIC_JWT",
|
|
scopes: ["read:query:runs"],
|
|
};
|
|
const result = checkAuthorization(entity, "read", { query: "llm_metrics" });
|
|
expect(result.authorized).toBe(false);
|
|
});
|
|
|
|
it("should grant access with general read:query scope to any table", () => {
|
|
const entity: AuthorizationEntity = {
|
|
type: "PUBLIC_JWT",
|
|
scopes: ["read:query"],
|
|
};
|
|
|
|
const runsResult = checkAuthorization(entity, "read", { query: "runs" });
|
|
expect(runsResult.authorized).toBe(true);
|
|
|
|
const metricsResult = checkAuthorization(entity, "read", { query: "metrics" });
|
|
expect(metricsResult.authorized).toBe(true);
|
|
|
|
const llmResult = checkAuthorization(entity, "read", { query: "llm_metrics" });
|
|
expect(llmResult.authorized).toBe(true);
|
|
});
|
|
|
|
it("should grant access to multiple tables when querying with super scope", () => {
|
|
const entity: AuthorizationEntity = {
|
|
type: "PUBLIC_JWT",
|
|
scopes: ["read:query"],
|
|
};
|
|
const result = checkAuthorization(
|
|
entity,
|
|
"read",
|
|
{ query: ["runs", "llm_metrics"] },
|
|
["read:query", "read:all", "admin"]
|
|
);
|
|
expect(result.authorized).toBe(true);
|
|
});
|
|
|
|
it("should grant access to schema with read:query scope", () => {
|
|
const entity: AuthorizationEntity = {
|
|
type: "PUBLIC_JWT",
|
|
scopes: ["read:query"],
|
|
};
|
|
const result = checkAuthorization(
|
|
entity,
|
|
"read",
|
|
{ query: "schema" },
|
|
["read:query", "read:all", "admin"]
|
|
);
|
|
expect(result.authorized).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Without super scope", () => {
|
|
const entityWithoutSuperPermissions: AuthorizationEntity = {
|
|
type: "PUBLIC_JWT",
|
|
scopes: ["read:tasks"],
|
|
};
|
|
|
|
it("should still grant access based on specific scope", () => {
|
|
const result = checkAuthorization(
|
|
entityWithoutSuperPermissions,
|
|
"read",
|
|
{ tasks: "task_1234" },
|
|
["read:all", "admin"]
|
|
);
|
|
expect(result.authorized).toBe(true);
|
|
expect(result).not.toHaveProperty("reason");
|
|
});
|
|
|
|
it("should deny access to resources not in scope", () => {
|
|
const result = checkAuthorization(
|
|
entityWithoutSuperPermissions,
|
|
"read",
|
|
{ runs: "run_5678" },
|
|
["read:all", "admin"]
|
|
);
|
|
expect(result.authorized).toBe(false);
|
|
if (!result.authorized) {
|
|
expect(result.reason).toBe(
|
|
"Public Access Token is missing required permissions. Token has the following permissions: 'read:tasks'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|