API - CODEX AGENTS: product tool boundary
This commit is contained in:
parent
14c5f490b1
commit
9f402074f2
24
README.md
24
README.md
|
|
@ -26,6 +26,7 @@ All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are
|
|||
- Internal REST endpoints for agent profile, grant, and token lifecycle.
|
||||
- Opaque agent tokens are generated once and stored only as SHA-256 hashes.
|
||||
- Authenticated agent-session endpoint returns effective grants/scopes for future MCP calls.
|
||||
- Product tool endpoints validate agent token, scopes, and project grants before calling Tasker internal adapter.
|
||||
- MCP and Tasker write execution are documented but not implemented yet.
|
||||
|
||||
## Local development
|
||||
|
|
@ -43,6 +44,7 @@ Useful checks:
|
|||
```bash
|
||||
npm run check
|
||||
npm run build
|
||||
npm run smoke:gateway
|
||||
curl http://127.0.0.1:4100/readyz
|
||||
curl http://127.0.0.1:4100/api/v1/meta/capabilities
|
||||
```
|
||||
|
|
@ -67,3 +69,25 @@ curl http://127.0.0.1:4100/api/v1/agent-session \
|
|||
```
|
||||
|
||||
Do not expose these lifecycle endpoints publicly before the Launcher/internal auth layer is added.
|
||||
|
||||
## Local testing strategy
|
||||
|
||||
No fake Tasker storage is embedded into Agent Gateway.
|
||||
|
||||
Local verification is split into product layers:
|
||||
|
||||
1. `npm run smoke:gateway` verifies real Agent Gateway persistence, bearer token auth, scope checks, grant checks, and the boundary before Tasker calls.
|
||||
2. Full localhost e2e starts after Tasker implements `/api/internal/nodedc/agent/...` adapter. Then the same Gateway tool endpoints call the real local Tasker runtime.
|
||||
3. External-machine testing uses the same token and endpoint shape against staging HTTPS; no extra protocol or fake environment should be introduced.
|
||||
|
||||
Current Tasker internal adapter contract expected by Gateway:
|
||||
|
||||
- `POST /api/internal/nodedc/agent/projects/resolve`
|
||||
- `GET /api/internal/nodedc/agent/projects/:projectId/context`
|
||||
- `GET /api/internal/nodedc/agent/issues?project_id=...`
|
||||
- `POST /api/internal/nodedc/agent/issues`
|
||||
- `PATCH /api/internal/nodedc/agent/issues/:issueId`
|
||||
- `POST /api/internal/nodedc/agent/issues/:issueId/move`
|
||||
- `POST /api/internal/nodedc/agent/issues/:issueId/comments`
|
||||
- `PUT /api/internal/nodedc/agent/issues/:issueId/labels`
|
||||
- `PUT /api/internal/nodedc/agent/issues/:issueId/assignees`
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ Exit criteria:
|
|||
|
||||
## Phase 1. Agent Gateway skeleton
|
||||
|
||||
Status: in progress. Initial service, migrations, persistence endpoints, token hashing, bearer-token session auth, local Postgres compose, and smoke checks are implemented.
|
||||
Status: in progress. Initial service, migrations, persistence endpoints, token hashing, bearer-token session auth, product tool endpoints, local Postgres compose, and Gateway smoke checks are implemented.
|
||||
|
||||
Create standalone service with:
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"check": "tsc --noEmit -p tsconfig.json",
|
||||
"migrate": "tsx src/scripts/migrate.ts",
|
||||
"migrate:dist": "node dist/scripts/migrate.js",
|
||||
"smoke:gateway": "tsx src/scripts/smoke-gateway.ts",
|
||||
"start": "node dist/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
46
src/app.ts
46
src/app.ts
|
|
@ -6,11 +6,18 @@ import { createPool, DatabaseNotConfiguredError } from "./db/pool.js";
|
|||
import { AgentsRepository } from "./repositories/agents.js";
|
||||
import { registerAgentRoutes } from "./routes/agents.js";
|
||||
import { registerHealthRoutes } from "./routes/health.js";
|
||||
import { registerToolRoutes } from "./routes/tools.js";
|
||||
import { ForbiddenError } from "./security/authorization.js";
|
||||
import { UnauthorizedError } from "./security/bearer.js";
|
||||
import { TaskerAdapterError, TaskerAdapterNotConfiguredError, TaskerAdapterUnavailableError, TaskerClient } from "./tasker/client.js";
|
||||
|
||||
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
const pool = createPool(config);
|
||||
const agentsRepository = pool ? new AgentsRepository(pool) : null;
|
||||
const taskerClient = new TaskerClient({
|
||||
baseUrl: config.NODEDC_TASKER_INTERNAL_URL,
|
||||
internalAccessToken: config.NODEDC_INTERNAL_ACCESS_TOKEN,
|
||||
});
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: config.LOG_LEVEL,
|
||||
|
|
@ -49,6 +56,44 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ForbiddenError) {
|
||||
void reply.status(403).send({
|
||||
ok: false,
|
||||
error: "forbidden",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof TaskerAdapterNotConfiguredError) {
|
||||
void reply.status(503).send({
|
||||
ok: false,
|
||||
error: "tasker_adapter_not_configured",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof TaskerAdapterError) {
|
||||
void reply.status(error.statusCode).send({
|
||||
ok: false,
|
||||
error: "tasker_adapter_error",
|
||||
message: error.message,
|
||||
tasker_status: error.statusCode,
|
||||
tasker_payload: error.payload,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof TaskerAdapterUnavailableError) {
|
||||
void reply.status(502).send({
|
||||
ok: false,
|
||||
error: "tasker_adapter_unavailable",
|
||||
message: error.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
void reply.status(500).send({
|
||||
ok: false,
|
||||
|
|
@ -59,6 +104,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
|||
|
||||
await registerHealthRoutes(app, config, pool);
|
||||
await registerAgentRoutes(app, { agentsRepository });
|
||||
await registerToolRoutes(app, { agentsRepository, taskerClient });
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import type { FastifyRequest } from "fastify";
|
||||
|
||||
import { DatabaseNotConfiguredError } from "../db/pool.js";
|
||||
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
|
||||
import { parseBearerToken, UnauthorizedError } from "../security/bearer.js";
|
||||
|
||||
export type SessionRouteDeps = {
|
||||
agentsRepository: AgentsRepository | null;
|
||||
};
|
||||
|
||||
export async function authenticateAgent(request: FastifyRequest, deps: SessionRouteDeps): Promise<AgentSessionRecord> {
|
||||
if (!deps.agentsRepository) {
|
||||
throw new DatabaseNotConfiguredError();
|
||||
}
|
||||
|
||||
const token = parseBearerToken(request.headers.authorization);
|
||||
const session = await deps.agentsRepository.findActiveSessionByToken(token);
|
||||
|
||||
if (!session) {
|
||||
throw new UnauthorizedError("Agent token is inactive, expired, or revoked.");
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentScope } from "../domain/scopes.js";
|
||||
import { structuredBlocksSchema } from "../domain/structured-blocks.js";
|
||||
import type { AgentsRepository, AgentSessionRecord } from "../repositories/agents.js";
|
||||
import { ForbiddenError, requireProjectGrant, requireScope } from "../security/authorization.js";
|
||||
import type { TaskerClient } from "../tasker/client.js";
|
||||
import { authenticateAgent } from "./session.js";
|
||||
|
||||
type ToolRouteDeps = {
|
||||
agentsRepository: AgentsRepository | null;
|
||||
taskerClient: TaskerClient;
|
||||
};
|
||||
|
||||
const projectParamsSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
});
|
||||
|
||||
const issueParamsSchema = z.object({
|
||||
issueId: z.string().min(1),
|
||||
});
|
||||
|
||||
const listIssuesQuerySchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).optional(),
|
||||
query: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const projectContextQuerySchema = z.object({
|
||||
workspace_slug: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const prioritySchema = z.enum(["none", "low", "medium", "high", "urgent"]);
|
||||
|
||||
const createIssueBodySchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).nullish(),
|
||||
title: z.string().min(1).max(500),
|
||||
description: z.string().max(20000).optional(),
|
||||
priority: prioritySchema.optional(),
|
||||
structured_blocks: structuredBlocksSchema.optional(),
|
||||
});
|
||||
|
||||
const updateIssueBodySchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).nullish(),
|
||||
title: z.string().min(1).max(500).optional(),
|
||||
description: z.string().max(20000).optional(),
|
||||
priority: prioritySchema.optional(),
|
||||
structured_blocks: structuredBlocksSchema.optional(),
|
||||
});
|
||||
|
||||
const moveIssueBodySchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).nullish(),
|
||||
state_id: z.string().min(1),
|
||||
});
|
||||
|
||||
const commentBodySchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).nullish(),
|
||||
body: z.string().min(1).max(20000),
|
||||
});
|
||||
|
||||
const labelsBodySchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).nullish(),
|
||||
label_ids: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
|
||||
const assigneesBodySchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
workspace_slug: z.string().min(1).nullish(),
|
||||
member_ids: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
|
||||
export async function registerToolRoutes(app: FastifyInstance, deps: ToolRouteDeps): Promise<void> {
|
||||
app.get("/api/v1/tools/projects", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
requireScope(session, "project:read");
|
||||
|
||||
return deps.taskerClient.listGrantedProjects(session);
|
||||
});
|
||||
|
||||
app.get("/api/v1/tools/projects/:projectId/context", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const { projectId } = projectParamsSchema.parse(request.params);
|
||||
const query = projectContextQuerySchema.parse(request.query);
|
||||
requireScope(session, "project:read");
|
||||
requireProjectGrant(session, { projectId, workspaceSlug: query.workspace_slug });
|
||||
|
||||
return deps.taskerClient.getProjectContext(session, projectId, query.workspace_slug);
|
||||
});
|
||||
|
||||
app.get("/api/v1/tools/issues", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const query = listIssuesQuerySchema.parse(request.query);
|
||||
requireScope(session, "issue:read");
|
||||
requireProjectGrant(session, { projectId: query.project_id, workspaceSlug: query.workspace_slug });
|
||||
|
||||
return deps.taskerClient.listIssues(session, query.project_id, query.workspace_slug, query.query);
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/issues", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const body = createIssueBodySchema.parse(request.body);
|
||||
requireToolAccess(session, "issue:create", body.project_id, body.workspace_slug);
|
||||
|
||||
return deps.taskerClient.createIssue(session, {
|
||||
project_id: body.project_id,
|
||||
workspace_slug: body.workspace_slug,
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
priority: body.priority,
|
||||
structured_blocks: body.structured_blocks,
|
||||
});
|
||||
});
|
||||
|
||||
app.patch("/api/v1/tools/issues/:issueId", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const { issueId } = issueParamsSchema.parse(request.params);
|
||||
const body = updateIssueBodySchema.parse(request.body);
|
||||
requireToolAccess(session, "issue:update", body.project_id, body.workspace_slug);
|
||||
|
||||
if (body.structured_blocks) {
|
||||
requireScope(session, "issue:structured_blocks:write");
|
||||
}
|
||||
|
||||
return deps.taskerClient.updateIssue(session, issueId, {
|
||||
project_id: body.project_id,
|
||||
workspace_slug: body.workspace_slug,
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
priority: body.priority,
|
||||
structured_blocks: body.structured_blocks,
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/issues/:issueId/move", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const { issueId } = issueParamsSchema.parse(request.params);
|
||||
const body = moveIssueBodySchema.parse(request.body);
|
||||
requireToolAccess(session, "issue:move", body.project_id, body.workspace_slug);
|
||||
|
||||
return deps.taskerClient.moveIssue(session, issueId, {
|
||||
project_id: body.project_id,
|
||||
workspace_slug: body.workspace_slug,
|
||||
state_id: body.state_id,
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/v1/tools/issues/:issueId/comments", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const { issueId } = issueParamsSchema.parse(request.params);
|
||||
const body = commentBodySchema.parse(request.body);
|
||||
requireToolAccess(session, "issue:comment", body.project_id, body.workspace_slug);
|
||||
|
||||
return deps.taskerClient.appendComment(session, issueId, {
|
||||
project_id: body.project_id,
|
||||
workspace_slug: body.workspace_slug,
|
||||
body: body.body,
|
||||
});
|
||||
});
|
||||
|
||||
app.put("/api/v1/tools/issues/:issueId/labels", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const { issueId } = issueParamsSchema.parse(request.params);
|
||||
const body = labelsBodySchema.parse(request.body);
|
||||
requireToolAccess(session, "issue:label", body.project_id, body.workspace_slug);
|
||||
|
||||
return deps.taskerClient.setLabels(session, issueId, {
|
||||
project_id: body.project_id,
|
||||
workspace_slug: body.workspace_slug,
|
||||
label_ids: body.label_ids,
|
||||
});
|
||||
});
|
||||
|
||||
app.put("/api/v1/tools/issues/:issueId/assignees", async (request) => {
|
||||
const session = await authenticateAgent(request, deps);
|
||||
const { issueId } = issueParamsSchema.parse(request.params);
|
||||
const body = assigneesBodySchema.parse(request.body);
|
||||
requireToolAccess(session, "issue:assign", body.project_id, body.workspace_slug);
|
||||
|
||||
return deps.taskerClient.assignIssue(session, issueId, {
|
||||
project_id: body.project_id,
|
||||
workspace_slug: body.workspace_slug,
|
||||
member_ids: body.member_ids,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function requireToolAccess(session: AgentSessionRecord, scope: AgentScope, projectId: string, workspaceSlug?: string | null): void {
|
||||
requireScope(session, scope);
|
||||
const grant = requireProjectGrant(session, { projectId, workspaceSlug });
|
||||
|
||||
if (!grant.scopes.includes(scope)) {
|
||||
throw new ForbiddenError(`Grant for project does not include required scope: ${scope}.`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { Pool } from "pg";
|
||||
|
||||
import { buildApp } from "../app.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { runMigrations } from "../db/migrations.js";
|
||||
|
||||
const envWithoutTaskerToken = { ...process.env };
|
||||
delete envWithoutTaskerToken.NODEDC_INTERNAL_ACCESS_TOKEN;
|
||||
envWithoutTaskerToken.LOG_LEVEL = "silent";
|
||||
|
||||
const config = loadConfig(envWithoutTaskerToken);
|
||||
|
||||
if (!config.DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL is required for gateway smoke test.");
|
||||
}
|
||||
|
||||
const migrationPool = new Pool({ connectionString: config.DATABASE_URL });
|
||||
await runMigrations(migrationPool);
|
||||
await migrationPool.end();
|
||||
|
||||
const app = await buildApp(config);
|
||||
|
||||
try {
|
||||
const suffix = Date.now().toString(36);
|
||||
const projectId = `contract-project-${suffix}`;
|
||||
const workspaceSlug = `contract-workspace-${suffix}`;
|
||||
|
||||
const createAgentResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/agents",
|
||||
payload: {
|
||||
owner_user_id: `smoke-owner-${suffix}`,
|
||||
owner_email: `smoke-${suffix}@example.test`,
|
||||
display_name: `Smoke Codex ${suffix}`,
|
||||
},
|
||||
});
|
||||
assertStatus(createAgentResponse.statusCode, 201, createAgentResponse.body);
|
||||
const createAgentPayload = JSON.parse(createAgentResponse.body);
|
||||
const agentId = createAgentPayload.agent.id as string;
|
||||
|
||||
const readGrantResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/v1/agents/${agentId}/grants`,
|
||||
payload: {
|
||||
workspace_slug: workspaceSlug,
|
||||
project_id: projectId,
|
||||
scopes: ["workspace:read", "project:read", "issue:read"],
|
||||
mode: "voluntary",
|
||||
created_by_user_id: "smoke-admin",
|
||||
},
|
||||
});
|
||||
assertStatus(readGrantResponse.statusCode, 201, readGrantResponse.body);
|
||||
|
||||
const createTokenResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/v1/agents/${agentId}/tokens`,
|
||||
payload: {
|
||||
name: "Gateway smoke token",
|
||||
},
|
||||
});
|
||||
assertStatus(createTokenResponse.statusCode, 201, createTokenResponse.body);
|
||||
const createTokenPayload = JSON.parse(createTokenResponse.body);
|
||||
const token = createTokenPayload.token as string;
|
||||
|
||||
const sessionResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/agent-session",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
assertStatus(sessionResponse.statusCode, 200, sessionResponse.body);
|
||||
|
||||
const deniedCreateResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/issues",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
payload: {
|
||||
project_id: projectId,
|
||||
workspace_slug: workspaceSlug,
|
||||
title: "Should be denied before Tasker call",
|
||||
},
|
||||
});
|
||||
assertStatus(deniedCreateResponse.statusCode, 403, deniedCreateResponse.body);
|
||||
|
||||
const writeGrantResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/v1/agents/${agentId}/grants`,
|
||||
payload: {
|
||||
workspace_slug: workspaceSlug,
|
||||
project_id: projectId,
|
||||
scopes: ["workspace:read", "project:read", "issue:read", "issue:create"],
|
||||
mode: "voluntary",
|
||||
created_by_user_id: "smoke-admin",
|
||||
},
|
||||
});
|
||||
assertStatus(writeGrantResponse.statusCode, 201, writeGrantResponse.body);
|
||||
|
||||
const allowedButNoAdapterResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/issues",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
payload: {
|
||||
project_id: projectId,
|
||||
workspace_slug: workspaceSlug,
|
||||
title: "Allowed by Gateway, waiting for Tasker adapter",
|
||||
},
|
||||
});
|
||||
assertStatus(allowedButNoAdapterResponse.statusCode, 503, allowedButNoAdapterResponse.body);
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
agent_id: agentId,
|
||||
token_prefix: token.split("_")[0],
|
||||
checks: {
|
||||
session_auth: "passed",
|
||||
denied_without_scope: "passed",
|
||||
allowed_request_reaches_tasker_boundary: "passed",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
function assertStatus(actual: number, expected: number, body: string): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected HTTP ${expected}, received HTTP ${actual}: ${body}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { AgentScope } from "../domain/scopes.js";
|
||||
import type { AgentGrantRecord, AgentSessionRecord } from "../repositories/agents.js";
|
||||
|
||||
export class ForbiddenError extends Error {
|
||||
constructor(message = "Agent is not allowed to perform this operation.") {
|
||||
super(message);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export function requireScope(session: AgentSessionRecord, scope: AgentScope): void {
|
||||
const hasScope = session.grants.some((grant) => grant.scopes.includes(scope));
|
||||
|
||||
if (!hasScope) {
|
||||
throw new ForbiddenError(`Missing required scope: ${scope}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function requireProjectGrant(
|
||||
session: AgentSessionRecord,
|
||||
input: {
|
||||
projectId: string;
|
||||
workspaceSlug?: string | null;
|
||||
}
|
||||
): AgentGrantRecord {
|
||||
const grant = session.grants.find((candidate) => {
|
||||
if (candidate.projectId === input.projectId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(input.workspaceSlug && candidate.projectId === null && candidate.workspaceSlug === input.workspaceSlug);
|
||||
});
|
||||
|
||||
if (!grant) {
|
||||
throw new ForbiddenError("Agent is not granted to this project.");
|
||||
}
|
||||
|
||||
return grant;
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import type { AgentSessionRecord } from "../repositories/agents.js";
|
||||
|
||||
export class TaskerAdapterNotConfiguredError extends Error {
|
||||
constructor() {
|
||||
super("NODEDC_INTERNAL_ACCESS_TOKEN is required for Tasker adapter calls.");
|
||||
this.name = "TaskerAdapterNotConfiguredError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskerAdapterError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode: number,
|
||||
readonly payload: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = "TaskerAdapterError";
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskerAdapterUnavailableError extends Error {
|
||||
constructor(readonly causeError: unknown) {
|
||||
super("Tasker internal adapter is unavailable.");
|
||||
this.name = "TaskerAdapterUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export type TaskerClientConfig = {
|
||||
baseUrl: string;
|
||||
internalAccessToken?: string;
|
||||
};
|
||||
|
||||
export type TaskerAgentContext = {
|
||||
agentId: string;
|
||||
ownerUserId: string;
|
||||
tokenId: string;
|
||||
};
|
||||
|
||||
export type GrantedProjectInput = {
|
||||
workspace_slug: string;
|
||||
project_id: string | null;
|
||||
mode: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
export type ListGrantedProjectsInput = {
|
||||
grants: GrantedProjectInput[];
|
||||
};
|
||||
|
||||
export type CreateIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: "none" | "low" | "medium" | "high" | "urgent";
|
||||
structured_blocks?: unknown[];
|
||||
};
|
||||
|
||||
export type UpdateIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
priority?: "none" | "low" | "medium" | "high" | "urgent";
|
||||
structured_blocks?: unknown[];
|
||||
};
|
||||
|
||||
export type MoveIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
state_id: string;
|
||||
};
|
||||
|
||||
export type CommentInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type SetLabelsInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
label_ids: string[];
|
||||
};
|
||||
|
||||
export type AssignIssueInput = {
|
||||
project_id: string;
|
||||
workspace_slug?: string | null;
|
||||
member_ids: string[];
|
||||
};
|
||||
|
||||
export class TaskerClient {
|
||||
constructor(private readonly config: TaskerClientConfig) {}
|
||||
|
||||
async listGrantedProjects(session: AgentSessionRecord): Promise<unknown> {
|
||||
return this.request("/api/internal/nodedc/agent/projects/resolve", {
|
||||
method: "POST",
|
||||
session,
|
||||
body: {
|
||||
grants: session.grants.map((grant) => ({
|
||||
workspace_slug: grant.workspaceSlug,
|
||||
project_id: grant.projectId,
|
||||
mode: grant.mode,
|
||||
scopes: grant.scopes,
|
||||
})),
|
||||
} satisfies ListGrantedProjectsInput,
|
||||
});
|
||||
}
|
||||
|
||||
async getProjectContext(session: AgentSessionRecord, projectId: string, workspaceSlug?: string | null): Promise<unknown> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (workspaceSlug) {
|
||||
searchParams.set("workspace_slug", workspaceSlug);
|
||||
}
|
||||
|
||||
return this.request(`/api/internal/nodedc/agent/projects/${encodeURIComponent(projectId)}/context?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
async listIssues(session: AgentSessionRecord, projectId: string, workspaceSlug?: string | null, query?: string): Promise<unknown> {
|
||||
const searchParams = new URLSearchParams({ project_id: projectId });
|
||||
|
||||
if (workspaceSlug) {
|
||||
searchParams.set("workspace_slug", workspaceSlug);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
searchParams.set("query", query);
|
||||
}
|
||||
|
||||
return this.request(`/api/internal/nodedc/agent/issues?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
session,
|
||||
});
|
||||
}
|
||||
|
||||
async createIssue(session: AgentSessionRecord, input: CreateIssueInput): Promise<unknown> {
|
||||
return this.request("/api/internal/nodedc/agent/issues", {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async updateIssue(session: AgentSessionRecord, issueId: string, input: UpdateIssueInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}`, {
|
||||
method: "PATCH",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async moveIssue(session: AgentSessionRecord, issueId: string, input: MoveIssueInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/move`, {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async appendComment(session: AgentSessionRecord, issueId: string, input: CommentInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/comments`, {
|
||||
method: "POST",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async setLabels(session: AgentSessionRecord, issueId: string, input: SetLabelsInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/labels`, {
|
||||
method: "PUT",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
async assignIssue(session: AgentSessionRecord, issueId: string, input: AssignIssueInput): Promise<unknown> {
|
||||
return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}/assignees`, {
|
||||
method: "PUT",
|
||||
session,
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
private async request(
|
||||
path: string,
|
||||
input: {
|
||||
method: "GET" | "POST" | "PATCH" | "PUT";
|
||||
session: AgentSessionRecord;
|
||||
body?: unknown;
|
||||
}
|
||||
): Promise<unknown> {
|
||||
if (!this.config.internalAccessToken) {
|
||||
throw new TaskerAdapterNotConfiguredError();
|
||||
}
|
||||
|
||||
const response = await this.fetchTasker(path, input);
|
||||
|
||||
const payload = await readResponsePayload(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new TaskerAdapterError("Tasker internal adapter request failed.", response.status, payload);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async fetchTasker(
|
||||
path: string,
|
||||
input: {
|
||||
method: "GET" | "POST" | "PATCH" | "PUT";
|
||||
session: AgentSessionRecord;
|
||||
body?: unknown;
|
||||
}
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await fetch(new URL(path, this.config.baseUrl), {
|
||||
method: input.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.internalAccessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-NODEDC-Agent-Id": input.session.agent.id,
|
||||
"X-NODEDC-Agent-Owner-User-Id": input.session.agent.ownerUserId,
|
||||
"X-NODEDC-Agent-Token-Id": input.session.token.id,
|
||||
},
|
||||
body: input.body === undefined ? undefined : JSON.stringify(input.body),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TaskerAdapterUnavailableError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readResponsePayload(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue