mirror of
https://github.com/Sea-Haven-Industries/sh-mcp.git
synced 2026-10-07 16:18:58 +00:00
Some checks are pending
deploy / deploy (push) Waiting to run
* 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.
243 lines
8.7 KiB
TypeScript
243 lines
8.7 KiB
TypeScript
/**
|
|
* CalendarClient interface + thin implementation.
|
|
*
|
|
* The real implementation calls the Google Calendar API v3 using a per-user
|
|
* OAuth2 access token supplied by the injected token provider. The actual
|
|
* googleapis SDK call is clearly stubbed — see the TODO below — so this file
|
|
* compiles and is safe to import without any network activity or AWS calls.
|
|
*
|
|
* Tests inject a MockCalendarClient that implements the same interface.
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Domain types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface CalendarEvent {
|
|
id: string;
|
|
summary: string;
|
|
description?: string;
|
|
start: string; // ISO-8601 datetime or date
|
|
end: string; // ISO-8601 datetime or date
|
|
attendees?: CalendarAttendee[];
|
|
/** True when at least one attendee is not @seahavenind.com */
|
|
hasExternalAttendees?: boolean;
|
|
status?: string; // confirmed | tentative | cancelled
|
|
htmlLink?: string;
|
|
}
|
|
|
|
export interface CalendarAttendee {
|
|
email: string;
|
|
displayName?: string;
|
|
responseStatus?: string; // accepted | declined | needsAction | tentative
|
|
organizer?: boolean;
|
|
self?: boolean;
|
|
}
|
|
|
|
export interface GetEventsOptions {
|
|
calendarId?: string; // defaults to 'primary'
|
|
timeMin: string; // ISO-8601
|
|
timeMax: string; // ISO-8601
|
|
maxResults?: number;
|
|
singleEvents?: boolean;
|
|
orderBy?: 'startTime' | 'updated';
|
|
}
|
|
|
|
export interface CheckAvailabilityOptions {
|
|
/** ISO-8601 start of window to check */
|
|
timeMin: string;
|
|
/** ISO-8601 end of window to check */
|
|
timeMax: string;
|
|
/** Defaults to 'primary' */
|
|
calendarId?: string;
|
|
}
|
|
|
|
export interface AvailabilityResult {
|
|
busy: Array<{ start: string; end: string }>;
|
|
free: Array<{ start: string; end: string }>;
|
|
}
|
|
|
|
export interface CreateEventOptions {
|
|
calendarId?: string; // defaults to 'primary'
|
|
summary: string;
|
|
description?: string;
|
|
start: string; // ISO-8601 datetime
|
|
end: string; // ISO-8601 datetime
|
|
attendees?: Array<{ email: string; displayName?: string }>;
|
|
location?: string;
|
|
timeZone?: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Interface — every consumer codes against this, never against the concrete impl
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface CalendarClient {
|
|
/**
|
|
* Returns events from the user's calendar in the requested window.
|
|
* Throws CalendarClientError on API errors.
|
|
*/
|
|
getEvents(userSub: string, opts: GetEventsOptions): Promise<CalendarEvent[]>;
|
|
|
|
/**
|
|
* Queries free/busy information for the user's calendar.
|
|
*/
|
|
checkAvailability(
|
|
userSub: string,
|
|
opts: CheckAvailabilityOptions,
|
|
): Promise<AvailabilityResult>;
|
|
|
|
/**
|
|
* Creates a calendar event and returns the created event.
|
|
* Callers MUST inspect hasExternalAttendees on the result and surface the
|
|
* warning to the user (design §2.5 — outbound invite monitoring).
|
|
*/
|
|
createEvent(userSub: string, opts: CreateEventOptions): Promise<CalendarEvent>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Error type
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class CalendarClientError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly statusCode?: number,
|
|
public readonly retryable: boolean = false,
|
|
) {
|
|
super(message);
|
|
this.name = 'CalendarClientError';
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Per-user token provider interface
|
|
//
|
|
// Real implementation fetches/refreshes the per-user Google OAuth2 refresh
|
|
// token from KMS-CMK-encrypted DynamoDB (keyed by user `sub`, ABAC-partitioned
|
|
// by LeadingKeys — design §2.4). This interface is injected so tests can mock
|
|
// it without any AWS/DDB calls at import time.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface GoogleTokenProvider {
|
|
/**
|
|
* Returns a short-lived Google OAuth2 access token scoped to ONLY
|
|
* `https://www.googleapis.com/auth/calendar` for the given user.
|
|
*
|
|
* The returned token is minted per-request and is never cached by this
|
|
* interface (design §2.4 — access tokens not cached or reused).
|
|
*
|
|
* Throws if the user has not yet granted the Calendar OAuth consent.
|
|
*/
|
|
getAccessToken(userSub: string): Promise<string>;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Concrete implementation (real call stubbed — see TODO)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Thin wrapper around the Google Calendar API v3.
|
|
*
|
|
* Construction is cheap (no network, no AWS) — the tokenProvider handles all
|
|
* credential retrieval lazily at call time.
|
|
*/
|
|
export class GoogleCalendarClient implements CalendarClient {
|
|
// Stored for use by the real implementation once the token store is available (design §2.4).
|
|
private readonly _tokenProvider: GoogleTokenProvider;
|
|
|
|
constructor(tokenProvider: GoogleTokenProvider) {
|
|
this._tokenProvider = tokenProvider;
|
|
// Mark as intentionally stored-but-unused until the real SDK call is wired.
|
|
void this._tokenProvider;
|
|
}
|
|
|
|
async getEvents(userSub: string, opts: GetEventsOptions): Promise<CalendarEvent[]> {
|
|
// TODO: replace the stub below with the real googleapis SDK call.
|
|
//
|
|
// import { google } from 'googleapis';
|
|
// const accessToken = await this.tokenProvider.getAccessToken(userSub);
|
|
// const auth = new google.auth.OAuth2();
|
|
// auth.setCredentials({ access_token: accessToken });
|
|
// const cal = google.calendar({ version: 'v3', auth });
|
|
// const res = await cal.events.list({
|
|
// calendarId: opts.calendarId ?? 'primary',
|
|
// timeMin: opts.timeMin,
|
|
// timeMax: opts.timeMax,
|
|
// maxResults: opts.maxResults ?? 50,
|
|
// singleEvents: opts.singleEvents ?? true,
|
|
// orderBy: opts.orderBy ?? 'startTime',
|
|
// });
|
|
// return (res.data.items ?? []).map(mapEvent);
|
|
//
|
|
// DEFERRED: awaiting the 0a-gated per-user token + DDB store (design §2.4).
|
|
void userSub;
|
|
void opts;
|
|
throw new CalendarClientError(
|
|
'GoogleCalendarClient.getEvents is not yet implemented — awaiting per-user token store (design §2.4)',
|
|
501,
|
|
false,
|
|
);
|
|
}
|
|
|
|
async checkAvailability(
|
|
userSub: string,
|
|
opts: CheckAvailabilityOptions,
|
|
): Promise<AvailabilityResult> {
|
|
// TODO: replace the stub below with the real googleapis SDK call.
|
|
//
|
|
// import { google } from 'googleapis';
|
|
// const accessToken = await this.tokenProvider.getAccessToken(userSub);
|
|
// const auth = new google.auth.OAuth2();
|
|
// auth.setCredentials({ access_token: accessToken });
|
|
// const cal = google.calendar({ version: 'v3', auth });
|
|
// const res = await cal.freebusy.query({
|
|
// requestBody: {
|
|
// timeMin: opts.timeMin,
|
|
// timeMax: opts.timeMax,
|
|
// items: [{ id: opts.calendarId ?? 'primary' }],
|
|
// },
|
|
// });
|
|
// return computeFreeBusy(opts.timeMin, opts.timeMax, res.data);
|
|
//
|
|
// DEFERRED: awaiting the 0a-gated per-user token + DDB store (design §2.4).
|
|
void userSub;
|
|
void opts;
|
|
throw new CalendarClientError(
|
|
'GoogleCalendarClient.checkAvailability is not yet implemented — awaiting per-user token store (design §2.4)',
|
|
501,
|
|
false,
|
|
);
|
|
}
|
|
|
|
async createEvent(userSub: string, opts: CreateEventOptions): Promise<CalendarEvent> {
|
|
// TODO: replace the stub below with the real googleapis SDK call.
|
|
//
|
|
// import { google } from 'googleapis';
|
|
// const accessToken = await this.tokenProvider.getAccessToken(userSub);
|
|
// const auth = new google.auth.OAuth2();
|
|
// auth.setCredentials({ access_token: accessToken });
|
|
// const cal = google.calendar({ version: 'v3', auth });
|
|
// const res = await cal.events.insert({
|
|
// calendarId: opts.calendarId ?? 'primary',
|
|
// requestBody: {
|
|
// summary: opts.summary,
|
|
// description: opts.description,
|
|
// start: { dateTime: opts.start, timeZone: opts.timeZone },
|
|
// end: { dateTime: opts.end, timeZone: opts.timeZone },
|
|
// attendees: opts.attendees,
|
|
// location: opts.location,
|
|
// },
|
|
// });
|
|
// return mapEvent(res.data);
|
|
//
|
|
// DEFERRED: awaiting the 0a-gated per-user token + DDB store (design §2.4).
|
|
void userSub;
|
|
void opts;
|
|
throw new CalendarClientError(
|
|
'GoogleCalendarClient.createEvent is not yet implemented — awaiting per-user token store (design §2.4)',
|
|
501,
|
|
false,
|
|
);
|
|
}
|
|
}
|