diff --git a/packages/@emulators/google/README.md b/packages/@emulators/google/README.md index ff3573a3..1a7f7772 100644 --- a/packages/@emulators/google/README.md +++ b/packages/@emulators/google/README.md @@ -61,6 +61,7 @@ npm install @emulators/google - `GET /gmail/v1/users/:userId/settings/sendAs` — list send-as aliases ### Calendar +- `GET /discovery/v1/apis/calendar/v3/rest` — Calendar API discovery document (no auth required) - `GET /calendar/v3/users/:userId/calendarList` — list calendars - `GET /calendar/v3/calendars/:calendarId/events` — list events - `POST /calendar/v3/calendars/:calendarId/events` — create event diff --git a/packages/@emulators/google/src/__tests__/google.test.ts b/packages/@emulators/google/src/__tests__/google.test.ts index 046d6942..65d1d244 100644 --- a/packages/@emulators/google/src/__tests__/google.test.ts +++ b/packages/@emulators/google/src/__tests__/google.test.ts @@ -1101,4 +1101,39 @@ describe("Google plugin integration", () => { expect(uploadedMediaRes.status).toBe(200); expect(Buffer.from(await uploadedMediaRes.arrayBuffer()).toString("utf8")).toBe(uploadedContent); }); + + it("serves Calendar discovery document at the standard path", async () => { + // No auth required — matches real Google behavior + const res = await app.request(`${base}/discovery/v1/apis/calendar/v3/rest`); + expect(res.status).toBe(200); + + const doc = (await res.json()) as any; + expect(doc.kind).toBe("discovery#restDescription"); + expect(doc.name).toBe("calendar"); + expect(doc.version).toBe("v3"); + expect(doc.id).toBe("calendar:v3"); + + // Verify URLs reflect the server origin (baseUrl/basePath required by SDKs) + expect(doc.rootUrl).toMatch(/^https?:\/\//); + expect(doc.baseUrl).toMatch(/^https?:\/\/.*\/calendar\/v3\/$/); + expect(doc.basePath).toBe("/calendar/v3/"); + expect(doc.servicePath).toBe("calendar/v3/"); + + // Verify all implemented resources are described + expect(doc.resources.calendarList.methods.list).toBeDefined(); + expect(doc.resources.calendarList.methods.list.httpMethod).toBe("GET"); + + expect(doc.resources.events.methods.list).toBeDefined(); + expect(doc.resources.events.methods.list.httpMethod).toBe("GET"); + expect(doc.resources.events.methods.list.parameters.calendarId.required).toBe(true); + + expect(doc.resources.events.methods.insert).toBeDefined(); + expect(doc.resources.events.methods.insert.httpMethod).toBe("POST"); + + expect(doc.resources.events.methods.delete).toBeDefined(); + expect(doc.resources.events.methods.delete.httpMethod).toBe("DELETE"); + + expect(doc.resources.freebusy.methods.query).toBeDefined(); + expect(doc.resources.freebusy.methods.query.httpMethod).toBe("POST"); + }); }); diff --git a/packages/@emulators/google/src/routes/calendar.ts b/packages/@emulators/google/src/routes/calendar.ts index a06d164a..a0a5393c 100644 --- a/packages/@emulators/google/src/routes/calendar.ts +++ b/packages/@emulators/google/src/routes/calendar.ts @@ -21,9 +21,102 @@ import { } from "../route-helpers.js"; import { getGoogleStore } from "../store.js"; -export function calendarRoutes({ app, store }: RouteContext): void { +export function calendarRoutes({ app, store, baseUrl }: RouteContext): void { const gs = getGoogleStore(store); + // Google API Discovery Document for Calendar v3. + // Enables google-api-python-client build("calendar", "v3") and similar SDK bootstrapping. + // Returns a minimal document describing the routes this emulator actually implements. + // No auth required — matches real Google behavior. + app.get("/discovery/v1/apis/calendar/v3/rest", (c) => { + const origin = baseUrl || new URL(c.req.url).origin; + return c.json({ + kind: "discovery#restDescription", + discoveryVersion: "v1", + id: "calendar:v3", + name: "calendar", + version: "v3", + title: "Calendar API", + description: "Manipulates events and other calendar data.", + protocol: "rest", + rootUrl: `${origin}/`, + basePath: "/calendar/v3/", + baseUrl: `${origin}/calendar/v3/`, + servicePath: "calendar/v3/", + batchPath: "batch/calendar/v3", + parameters: {}, + schemas: {}, + resources: { + calendarList: { + methods: { + list: { + id: "calendar.calendarList.list", + path: "users/me/calendarList", + httpMethod: "GET", + description: "Returns the calendars on the user's calendar list.", + response: { $ref: "CalendarList" }, + }, + }, + }, + events: { + methods: { + list: { + id: "calendar.events.list", + path: "calendars/{calendarId}/events", + httpMethod: "GET", + description: "Returns events on the specified calendar.", + parameters: { + calendarId: { type: "string", required: true, location: "path" }, + timeMin: { type: "string", location: "query", description: "Lower bound (RFC3339) for event end time." }, + timeMax: { type: "string", location: "query", description: "Upper bound (RFC3339) for event start time." }, + maxResults: { type: "integer", location: "query", description: "Maximum number of events returned." }, + pageToken: { type: "string", location: "query", description: "Token for pagination." }, + q: { type: "string", location: "query", description: "Free text search terms." }, + orderBy: { type: "string", location: "query", description: "Sort order (startTime or updated)." }, + singleEvents: { type: "boolean", location: "query", description: "Whether to expand recurring events into instances." }, + showDeleted: { type: "boolean", location: "query", description: "Whether to include deleted events." }, + }, + response: { $ref: "Events" }, + }, + insert: { + id: "calendar.events.insert", + path: "calendars/{calendarId}/events", + httpMethod: "POST", + description: "Creates an event.", + parameters: { + calendarId: { type: "string", required: true, location: "path" }, + }, + request: { $ref: "Event" }, + response: { $ref: "Event" }, + }, + delete: { + id: "calendar.events.delete", + path: "calendars/{calendarId}/events/{eventId}", + httpMethod: "DELETE", + description: "Deletes an event.", + parameters: { + calendarId: { type: "string", required: true, location: "path" }, + eventId: { type: "string", required: true, location: "path" }, + }, + }, + }, + }, + freebusy: { + methods: { + query: { + id: "calendar.freebusy.query", + path: "freeBusy", + httpMethod: "POST", + description: "Returns free/busy information for a set of calendars and groups.", + request: { $ref: "FreeBusyRequest" }, + response: { $ref: "FreeBusyResponse" }, + }, + }, + }, + }, + }); + }); + app.get("/calendar/v3/users/:userId/calendarList", (c) => { const authEmail = requireGmailUser(c); if (authEmail instanceof Response) return authEmail; diff --git a/packages/@emulators/google/src/routes/oauth.ts b/packages/@emulators/google/src/routes/oauth.ts index e8b1c1df..2bba2de8 100644 --- a/packages/@emulators/google/src/routes/oauth.ts +++ b/packages/@emulators/google/src/routes/oauth.ts @@ -3,7 +3,6 @@ import { SignJWT } from "jose"; import type { RouteContext } from "@emulators/core"; import { escapeHtml, - escapeAttr, renderCardPage, renderErrorPage, renderUserButton, @@ -313,6 +312,11 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo return c.json({ error: "invalid_grant", error_description: "The code is incorrect or expired." }, 400); } + // RFC 6749 §4.1.3: redirect_uri must match the one used in the authorization request. + if (redirect_uri && redirect_uri !== pending.redirectUri) { + return c.json({ error: "invalid_grant", error_description: "The redirect_uri does not match." }, 400); + } + if (pending.codeChallenge != null) { if (code_verifier === undefined) { return c.json({ error: "invalid_grant", error_description: "PKCE verification failed." }, 400); diff --git a/skills/google/SKILL.md b/skills/google/SKILL.md index be563d18..5a5be37d 100644 --- a/skills/google/SKILL.md +++ b/skills/google/SKILL.md @@ -469,6 +469,16 @@ curl http://localhost:4002/gmail/v1/users/me/settings/sendAs \ ## Google Calendar API +### Discovery Document + +The emulator serves the Calendar v3 [Google API discovery document](https://developers.google.com/discovery/v1/reference/apis) so SDKs that bootstrap from discovery (`google-api-python-client`, `googleapis-discovery`, generated clients) work against the local URL with no code changes: + +```bash +curl http://localhost:4002/discovery/v1/apis/calendar/v3/rest +``` + +`rootUrl` and `baseUrl` in the response point at the running emulator (resolved from the request origin, or from `--base-url` if set), so any SDK that follows discovery will route subsequent calls through the emulator. + ### Calendar List ```bash