Files
triggerdotdev--trigger.dev/apps/webapp/test/authorization.test.ts
Eric Allam dbbe9f77f9 feat(cli): Expand and improve the MCP server and dev CLI command (#3224)
## 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)
2026-03-17 11:38:23 +00:00

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."
);
}
});
});
});