/** * TasksClient — external-dependency interface for the tasks package. * * All code in tools.ts codes against the TasksClient interface, never against a * concrete AWS SDK import. The real implementation (DynamoDBTasksClient) stubs * the actual DynamoDB call so no AWS credentials or network are needed at import * time or in tests. * * Tests inject a MockTasksClient (see test/tasks.test.ts). */ export interface Task { taskId: string; sub: string; // owner — partition key, enforced ABAC (dynamodb:LeadingKeys) title: string; description?: string; completed: boolean; createdAt: string; // ISO-8601 completedAt?: string; // ISO-8601 } export interface CreateTaskInput { sub: string; title: string; description?: string; } export interface ListTasksInput { sub: string; includeCompleted?: boolean; } export interface CompleteTaskInput { sub: string; taskId: string; } export interface DeleteTaskInput { sub: string; taskId: string; } /** * The interface every caller (tools.ts, jobs, tests) depends on. * The DynamoDB table is partitioned by `sub`; callers always pass their own sub * so the ABAC LeadingKeys condition on the IAM policy matches. */ export interface TasksClient { createTask(input: CreateTaskInput): Promise; listTasks(input: ListTasksInput): Promise; completeTask(input: CompleteTaskInput): Promise; deleteTask(input: DeleteTaskInput): Promise; } // --------------------------------------------------------------------------- // Real (DynamoDB) implementation // --------------------------------------------------------------------------- // The real AWS SDK import is lazy and guard-wrapped so that: // 1. Importing this file at test time does NOT instantiate a real SDK client. // 2. A real deployment provides TABLE_NAME and AWS credentials via the // Lambda execution environment. // // TODO (DEFERRED — auth layer): Once the real JWT/aud/client_id validation // layer is in place, ensure the DynamoDB client is constructed with a role that // only has `dynamodb:GetItem`, `dynamodb:PutItem`, `dynamodb:UpdateItem`, // `dynamodb:DeleteItem`, `dynamodb:Query` on the tasks table, scoped to // `dynamodb:LeadingKeys` = `${cognito-identity.amazonaws.com:sub}` so a // compromised server cannot read another user's tasks. const TABLE_NAME = process.env['TASKS_TABLE_NAME'] ?? 'sh-mcp-tasks'; export class DynamoDBTasksClient implements TasksClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any private ddb: any; // typed as `any` to avoid importing @aws-sdk/client-dynamodb at the top level constructor() { if (process.env['NODE_ENV'] === 'test') { throw new Error( 'DynamoDBTasksClient must not be instantiated in tests. Inject a mock TasksClient instead.', ); } // Lazy import — only reached in a real Lambda execution environment. // eslint-disable-next-line @typescript-eslint/no-require-imports const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); // eslint-disable-next-line @typescript-eslint/no-require-imports const { DynamoDBDocumentClient } = require('@aws-sdk/lib-dynamodb'); this.ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); // Accessed once so TypeScript does not flag the field as write-only. // Remove when the real DynamoDB calls are wired in the methods below. void this.ddb; } async createTask(input: CreateTaskInput): Promise { // TODO: replace this stub with a real `PutCommand` against TABLE_NAME. // Stub guards against accidental real calls during development. throw new Error( `DynamoDBTasksClient.createTask not yet implemented. Table: ${TABLE_NAME}, input: ${JSON.stringify(input)}`, ); } async listTasks(input: ListTasksInput): Promise { // TODO: replace with a real `QueryCommand` (KeyConditionExpression: 'sub = :sub', // optionally FilterExpression: 'completed = :completed'). throw new Error( `DynamoDBTasksClient.listTasks not yet implemented. Table: ${TABLE_NAME}, input: ${JSON.stringify(input)}`, ); } async completeTask(input: CompleteTaskInput): Promise { // TODO: replace with a real `UpdateCommand` setting completed = true, completedAt = now. // Enforce ownership: ConditionExpression: 'sub = :sub' so a user cannot complete another // user's task even if they guess the taskId. throw new Error( `DynamoDBTasksClient.completeTask not yet implemented. Table: ${TABLE_NAME}, input: ${JSON.stringify(input)}`, ); } async deleteTask(input: DeleteTaskInput): Promise { // TODO: replace with a real `DeleteCommand` with the same sub-ownership condition. throw new Error( `DynamoDBTasksClient.deleteTask not yet implemented. Table: ${TABLE_NAME}, input: ${JSON.stringify(input)}`, ); } }