diff --git a/README.md b/README.md index 17b3524..611f213 100644 --- a/README.md +++ b/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` diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 6de6663..ec4b223 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -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: diff --git a/package.json b/package.json index fcb9cc2..9735c82 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/app.ts b/src/app.ts index d704271..00258e0 100644 --- a/src/app.ts +++ b/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 { 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 { 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 { await registerHealthRoutes(app, config, pool); await registerAgentRoutes(app, { agentsRepository }); + await registerToolRoutes(app, { agentsRepository, taskerClient }); return app; } diff --git a/src/routes/session.ts b/src/routes/session.ts new file mode 100644 index 0000000..0acf846 --- /dev/null +++ b/src/routes/session.ts @@ -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 { + 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; +} diff --git a/src/routes/tools.ts b/src/routes/tools.ts new file mode 100644 index 0000000..54cd86b --- /dev/null +++ b/src/routes/tools.ts @@ -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 { + 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}.`); + } +} diff --git a/src/scripts/smoke-gateway.ts b/src/scripts/smoke-gateway.ts new file mode 100644 index 0000000..49e020b --- /dev/null +++ b/src/scripts/smoke-gateway.ts @@ -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}`); + } +} diff --git a/src/security/authorization.ts b/src/security/authorization.ts new file mode 100644 index 0000000..c943712 --- /dev/null +++ b/src/security/authorization.ts @@ -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; +} diff --git a/src/tasker/client.ts b/src/tasker/client.ts new file mode 100644 index 0000000..ac24d70 --- /dev/null +++ b/src/tasker/client.ts @@ -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 { + 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 { + 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 { + 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 { + return this.request("/api/internal/nodedc/agent/issues", { + method: "POST", + session, + body: input, + }); + } + + async updateIssue(session: AgentSessionRecord, issueId: string, input: UpdateIssueInput): Promise { + return this.request(`/api/internal/nodedc/agent/issues/${encodeURIComponent(issueId)}`, { + method: "PATCH", + session, + body: input, + }); + } + + async moveIssue(session: AgentSessionRecord, issueId: string, input: MoveIssueInput): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const text = await response.text(); + + if (!text) { + return null; + } + + try { + return JSON.parse(text); + } catch { + return text; + } +}