sh-mcp/packages/internal-data/src/client.ts

177 lines
8 KiB
TypeScript
Raw Normal View History

Phase 0b slice: monorepo scaffold + @sh-mcp/shared core + integration packages (#2) * 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). * Complete Cognito auth provider + Phase 1 build brief Finish the WIP CognitoAuthProvider (client_id allow-list as audience boundary, finance TTL ceiling, deny-list, scope-prefix stripping) with its test suite, and check in docs/build-plan-phase-1.md so the Phase 1 work has its governing brief in-tree (design.md §2.5). * ci: disable cdk synth for Phase 0b (no CDK app yet) The reusable ci-typescript-cdk workflow defaults run-cdk-synth: true, but the Phase 0b package scaffold has no cdk.json or stacks, so cdk synth fails with '--app is required'. Disable it here; Phase 1 re-enables it with the server CDK stubs.
2026-06-26 12:42:17 -04:00
/**
* DynamoDB client interface + thin implementation.
*
* The interface is what every tool handler receives — callers (including tests)
* inject any object that satisfies it. The real implementation wraps the AWS SDK
* DynamoDB DocumentClient, but the SDK is only instantiated when
* RealDynamoClient.create() is explicitly called; nothing happens at import time
* and no AWS calls are made unless you call a method.
*
* Tables used by this package:
* WorkOrders – work-order records, keyed on `workOrderId` (PK)
* purchase-orders – purchase-order records, keyed on `purchaseOrderId` (PK)
* SiteAssignments – site records, keyed on `siteId` (PK)
*/
// ---------------------------------------------------------------------------
// DynamoDB record shapes returned from each table
// ---------------------------------------------------------------------------
export interface WorkOrderRecord {
workOrderId: string;
title: string;
status: string;
siteId?: string;
assignedTo?: string;
createdAt: string;
updatedAt: string;
description?: string;
[key: string]: unknown;
}
export interface PurchaseOrderRecord {
purchaseOrderId: string;
vendor: string;
status: string;
totalAmount?: number;
currency?: string;
issuedAt: string;
updatedAt: string;
lineItems?: Array<{ description: string; quantity: number; unitPrice: number }>;
[key: string]: unknown;
}
export interface SiteRecord {
siteId: string;
name: string;
address?: string;
region?: string;
status: string;
assignedTechnicians?: string[];
[key: string]: unknown;
}
// ---------------------------------------------------------------------------
// Client interface — inject this everywhere; never import the AWS SDK directly
// ---------------------------------------------------------------------------
export interface InternalDataClient {
getWorkOrder(workOrderId: string): Promise<WorkOrderRecord | null>;
getPurchaseOrder(purchaseOrderId: string): Promise<PurchaseOrderRecord | null>;
getSite(siteId: string): Promise<SiteRecord | null>;
}
// ---------------------------------------------------------------------------
// Real (AWS SDK-backed) implementation
//
// The AWS SDK import lives here — behind this class — so that:
// a) Nothing happens at module load time (no credential resolution, no env reads).
// b) Tests never reach this code; they inject a mock that satisfies the interface.
//
// TODO (DEFERRED auth layer): When the Gateway layer is built, the Lambda execution
// role will supply credentials via the standard AWS environment variables. At that
// point ensure the DocumentClient is constructed with the correct region and that
// the table names are injected via environment variables (WORK_ORDERS_TABLE,
// PURCHASE_ORDERS_TABLE, SITE_ASSIGNMENTS_TABLE) rather than hard-coded.
// ---------------------------------------------------------------------------
export class RealDynamoClient implements InternalDataClient {
// Table names — override via environment variables at Lambda deploy time.
private readonly workOrdersTable: string;
private readonly purchaseOrdersTable: string;
private readonly siteAssignmentsTable: string;
// The DocumentClient is typed as `unknown` here to avoid importing the AWS SDK
// at module scope. It is cast when needed inside each method.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly ddb: any;
private constructor(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ddb: any,
workOrdersTable: string,
purchaseOrdersTable: string,
siteAssignmentsTable: string,
) {
this.ddb = ddb;
this.workOrdersTable = workOrdersTable;
this.purchaseOrdersTable = purchaseOrdersTable;
this.siteAssignmentsTable = siteAssignmentsTable;
}
/**
* Factory — the only place the AWS SDK DocumentClient is instantiated.
* Calling this from a Lambda handler (not at module scope) is the correct pattern.
*
* NOTE: @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb are intentionally absent
* from package.json until the Lambda runtime bundle is assembled (see TODO above).
* The module specifiers are stored in runtime variables so TypeScript does not attempt
* static module-resolution at build time.
*/
static async create(): Promise<RealDynamoClient> {
// Store specifiers in variables to prevent TypeScript static module resolution.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { DynamoDBClient } = (await dynImport('@aws-sdk/client-dynamodb')) as { DynamoDBClient: new (cfg: { region: string }) => any };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { DynamoDBDocumentClient } = (await dynImport('@aws-sdk/lib-dynamodb')) as { DynamoDBDocumentClient: { from: (c: any) => any } };
const region = process.env['AWS_REGION'] ?? 'us-east-1';
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
const raw = new DynamoDBClient({ region });
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
const ddb = DynamoDBDocumentClient.from(raw);
return new RealDynamoClient(
ddb,
process.env['WORK_ORDERS_TABLE'] ?? 'WorkOrders',
process.env['PURCHASE_ORDERS_TABLE'] ?? 'purchase-orders',
process.env['SITE_ASSIGNMENTS_TABLE'] ?? 'SiteAssignments',
);
}
async getWorkOrder(workOrderId: string): Promise<WorkOrderRecord | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { GetCommand: new (i: any) => any };
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
const result = await this.ddb.send(
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
new GetCommand({ TableName: this.workOrdersTable, Key: { workOrderId } }),
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return (result.Item as WorkOrderRecord) ?? null;
}
async getPurchaseOrder(purchaseOrderId: string): Promise<PurchaseOrderRecord | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { GetCommand: new (i: any) => any };
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
const result = await this.ddb.send(
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
new GetCommand({ TableName: this.purchaseOrdersTable, Key: { purchaseOrderId } }),
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return (result.Item as PurchaseOrderRecord) ?? null;
}
async getSite(siteId: string): Promise<SiteRecord | null> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dynImport = (s: string): Promise<any> => import(/* @vite-ignore */ s);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { GetCommand } = (await dynImport('@aws-sdk/lib-dynamodb')) as { GetCommand: new (i: any) => any };
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
const result = await this.ddb.send(
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
new GetCommand({ TableName: this.siteAssignmentsTable, Key: { siteId } }),
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return (result.Item as SiteRecord) ?? null;
}
}