sh-mcp/packages/calendar/src/client.ts
Adam Moussa 0d1fefb326
Some checks are pending
deploy / deploy (push) Waiting to run
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

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,
);
}
}