sh-mcp/packages/tasks/src/client.ts
Adam Moussa 8d3de8a447 Phase 0b slice: monorepo scaffold + shared core + integration packages
The 0a-INDEPENDENT code slice (one-shot via af-0b-package-slice workflow: Haiku
scaffold + Sonnet packages, Sonnet fix-to-green). Nothing deploys; no CDK/servers.

- Monorepo scaffold: npm workspaces, strict TS (NodeNext), vitest (80% gate),
  eslint 9 flat config, prettier; ci.yaml/deploy.yaml callers (Node 24, enable-qemu).
- @sh-mcp/shared: transport-agnostic core — Scope/AuthContext/ToolDef, ToolRegistry,
  redact()+maskValue() (PII), OpenAPI 3.1 generator. AUTH STUBBED behind an AuthProvider
  interface (TODO auth-layer-0a); JWT/aud/client_id/JWKS/deny-list deferred per design.md §2.
- 9 integration packages (qbo, google-maps, internal-data, payments, knowledge-base,
  gmail, calendar, tasks, reminders): tools against shared, external deps mocked behind
  injected client interfaces; finance handlers call redact().

Verified green: tsc -b clean, vitest 245/245, eslint 0 errors. Auth mechanism intentionally
deferred until the 0a spike resolves it (G16/§0.4).
2026-06-11 14:36:28 -04:00

124 lines
4.8 KiB
TypeScript

/**
* 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<Task>;
listTasks(input: ListTasksInput): Promise<Task[]>;
completeTask(input: CompleteTaskInput): Promise<Task>;
deleteTask(input: DeleteTaskInput): Promise<void>;
}
// ---------------------------------------------------------------------------
// 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<Task> {
// 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<Task[]> {
// 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<Task> {
// 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<void> {
// 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)}`,
);
}
}