From e9c39c5f87ac94bf8b480c619ddfe2a4b8daeba9 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Wed, 23 Sep 2026 09:49:17 +0000 Subject: [PATCH] Add implementation plan for Google Calendar connect flow --- ...2026-09-23-google-calendar-connect-flow.md | 1314 +++++++++++++++++ 1 file changed, 1314 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-23-google-calendar-connect-flow.md diff --git a/docs/superpowers/plans/2026-09-23-google-calendar-connect-flow.md b/docs/superpowers/plans/2026-09-23-google-calendar-connect-flow.md new file mode 100644 index 0000000..99e84a6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-google-calendar-connect-flow.md @@ -0,0 +1,1314 @@ +# In-bot Google Calendar connect flow — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the manual "run a script, paste `GOOGLE_REFRESH_TOKEN` into `.env`, restart" bootstrap with two Telegram slash commands (`/connect_calendar` + `/calendar_done`) that link the owner's Google account via Google's device-authorization flow. Refresh token moves out of env into `workspace/.secretary/google-token.json`. + +**Architecture:** File-backed `GoogleTokenStore` reads/writes the refresh token. A `DeviceFlowClient` module wraps Google's two OAuth device endpoints. The bridge adds two slash commands with in-memory pending-flow state. The calendar tool consults the store on every access-token mint, so a fresh connect takes effect without restart. + +**Tech Stack:** TypeScript, Node.js `fs`, grammy, vitest. Uses `fetch` (already used in the calendar tool). + +**Spec:** `docs/superpowers/specs/2026-09-23-google-calendar-connect-flow-design.md` + +--- + +## File plan + +**Create:** +- `src/tools/google-token-store.ts` — file-backed refresh-token store. +- `src/tools/google-token-store.test.ts` — store unit tests. +- `src/telegram/google-device-flow.ts` — Google device-flow HTTP client. +- `src/telegram/google-device-flow.test.ts` — device-flow unit tests. + +**Modify:** +- `src/config.ts` — refresh token becomes optional; validation loosened accordingly. +- `src/config.test.ts` — add cases for id+secret without refresh token, and id-alone rejection. +- `src/tools/calendar.ts` — `createCalendarTool` takes a `CalendarDeps` object (store + client id/secret), consults the store on each mint, invalidates cached access token when the stored refresh token changes. +- `src/tools/calendar.test.ts` — add tests for `createCalendarTool` (store-driven token mint, "not connected" behaviour, refresh-token rotation, `invalid_grant` message). +- `src/telegram/bridge.ts` — new `deviceFlow` / `tokenStore` fields on `BridgeDeps`, two new commands, module-level `pendingDevice` state. +- `src/telegram/bridge.test.ts` — new tests for the two commands' state transitions. +- `src/main.ts` — instantiate store, migrate `GOOGLE_REFRESH_TOKEN` env into store on first boot, instantiate device-flow client, pass both into the bridge and the calendar tool. +- `.env.example` — reword the Google block; mark `GOOGLE_REFRESH_TOKEN` deprecated. +- `README.md` — replace bootstrap-script mention with the new two-command flow, add `/connect_calendar` + `/calendar_done` to the commands line, add manual E2E item #21. + +--- + +## Task 1: Google token store — TDD, isolated module + +**Files:** +- Create: `src/tools/google-token-store.ts` +- Test: `src/tools/google-token-store.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/tools/google-token-store.test.ts`: + +```ts +import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createGoogleTokenStore } from "./google-token-store.js"; + +describe("createGoogleTokenStore", () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), "vs-token-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("read returns null when the file does not exist", () => { + const store = createGoogleTokenStore(dir); + expect(store.read()).toBeNull(); + }); + + it("read returns null when the file is empty, malformed, or missing the key", () => { + const store = createGoogleTokenStore(dir); + const p = path.join(dir, ".secretary", "google-token.json"); + // Empty + writeFileSync(p, "", { mode: 0o600 }); + expect(store.read()).toBeNull(); + // Malformed + writeFileSync(p, "{not-json", { mode: 0o600 }); + expect(store.read()).toBeNull(); + // Right shape but no key + writeFileSync(p, JSON.stringify({ other: "x" }), { mode: 0o600 }); + expect(store.read()).toBeNull(); + }); + + it("write creates the .secretary directory if it does not exist and round-trips through read", () => { + const store = createGoogleTokenStore(dir); + store.write("refresh-token-abc"); + expect(store.read()).toBe("refresh-token-abc"); + }); + + it("write replaces a previous token", () => { + const store = createGoogleTokenStore(dir); + store.write("first"); + store.write("second"); + expect(store.read()).toBe("second"); + }); + + it("write applies mode 0600 on POSIX", () => { + if (process.platform === "win32") return; + const store = createGoogleTokenStore(dir); + store.write("t"); + const mode = statSync(path.join(dir, ".secretary", "google-token.json")).mode & 0o777; + expect(mode).toBe(0o600); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/tools/google-token-store.test.ts` + +Expected: FAIL with "Cannot find module './google-token-store.js'" (or similar import error). + +- [ ] **Step 3: Write minimal implementation** + +Create `src/tools/google-token-store.ts`: + +```ts +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +export interface GoogleTokenStore { + read(): string | null; + write(refreshToken: string): void; +} + +export function createGoogleTokenStore(workspaceDir: string): GoogleTokenStore { + const dir = path.join(workspaceDir, ".secretary"); + const file = path.join(dir, "google-token.json"); + return { + read() { + if (!existsSync(file)) return null; + try { + const raw = readFileSync(file, "utf8"); + if (!raw.trim()) return null; + const parsed = JSON.parse(raw) as { refresh_token?: unknown }; + return typeof parsed.refresh_token === "string" && parsed.refresh_token + ? parsed.refresh_token + : null; + } catch { + return null; + } + }, + write(refreshToken: string) { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(file, JSON.stringify({ refresh_token: refreshToken }), { mode: 0o600 }); + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/tools/google-token-store.test.ts` + +Expected: PASS, 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/tools/google-token-store.ts src/tools/google-token-store.test.ts +git commit -m "Add GoogleTokenStore for workspace-local refresh token" +``` + +--- + +## Task 2: Google device-flow client — TDD, isolated module + +**Files:** +- Create: `src/telegram/google-device-flow.ts` +- Test: `src/telegram/google-device-flow.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/telegram/google-device-flow.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { createDeviceFlowClient } from "./google-device-flow.js"; + +type FetchCall = { url: string; init?: RequestInit }; + +function stubFetch(responses: Array<{ status?: number; body: unknown }>) { + const calls: FetchCall[] = []; + let i = 0; + const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push({ url, init }); + const entry = responses[i++]; + if (!entry) throw new Error(`unexpected fetch call: ${url}`); + const status = entry.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(entry.body), + json: async () => entry.body, + } as Response; + }); + return { fn, calls }; +} + +const opts = { clientId: "cid", clientSecret: "csecret" }; + +describe("createDeviceFlowClient.start", () => { + it("POSTs client_id and scope, parses the response, and computes expiresAt", async () => { + const { fn, calls } = stubFetch([ + { + body: { + device_code: "dev-1", + user_code: "ABCD-EFGH", + verification_url: "https://google.com/device", + expires_in: 900, + interval: 5, + }, + }, + ]); + const before = Date.now(); + const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch }); + const code = await client.start(); + const after = Date.now(); + + expect(calls[0].url).toBe("https://oauth2.googleapis.com/device/code"); + expect(calls[0].init?.method).toBe("POST"); + const body = new URLSearchParams(calls[0].init!.body as string); + expect(body.get("client_id")).toBe("cid"); + expect(body.get("scope")).toBe("https://www.googleapis.com/auth/calendar"); + + expect(code.deviceCode).toBe("dev-1"); + expect(code.userCode).toBe("ABCD-EFGH"); + expect(code.verificationUrl).toBe("https://google.com/device"); + expect(code.interval).toBe(5); + expect(code.expiresAt).toBeGreaterThanOrEqual(before + 900_000); + expect(code.expiresAt).toBeLessThanOrEqual(after + 900_000); + }); +}); + +describe("createDeviceFlowClient.complete", () => { + it("returns success and extracts the refresh_token", async () => { + const { fn, calls } = stubFetch([ + { body: { access_token: "at", refresh_token: "rt", expires_in: 3600, token_type: "Bearer" } }, + ]); + const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch }); + const result = await client.complete("dev-1"); + expect(result).toEqual({ kind: "success", refreshToken: "rt" }); + const body = new URLSearchParams(calls[0].init!.body as string); + expect(body.get("client_id")).toBe("cid"); + expect(body.get("client_secret")).toBe("csecret"); + expect(body.get("device_code")).toBe("dev-1"); + expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code"); + }); + + it("returns pending for authorization_pending", async () => { + const { fn } = stubFetch([{ status: 400, body: { error: "authorization_pending" } }]); + const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch }); + expect(await client.complete("dev-1")).toEqual({ kind: "pending" }); + }); + + it("returns slow_down for slow_down", async () => { + const { fn } = stubFetch([{ status: 400, body: { error: "slow_down" } }]); + const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch }); + expect(await client.complete("dev-1")).toEqual({ kind: "slow_down" }); + }); + + it("returns denied for access_denied", async () => { + const { fn } = stubFetch([{ status: 400, body: { error: "access_denied" } }]); + const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch }); + expect(await client.complete("dev-1")).toEqual({ kind: "denied" }); + }); + + it("returns expired for expired_token", async () => { + const { fn } = stubFetch([{ status: 400, body: { error: "expired_token" } }]); + const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch }); + expect(await client.complete("dev-1")).toEqual({ kind: "expired" }); + }); + + it("returns error for unknown error codes and non-400 failures", async () => { + const { fn } = stubFetch([{ status: 400, body: { error: "internal_error", error_description: "boom" } }]); + const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch }); + const r = await client.complete("dev-1"); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toMatch(/internal_error/); + }); + + it("returns error when the success body is missing refresh_token", async () => { + const { fn } = stubFetch([{ body: { access_token: "at", expires_in: 3600 } }]); + const client = createDeviceFlowClient({ ...opts, fetchFn: fn as unknown as typeof fetch }); + const r = await client.complete("dev-1"); + expect(r.kind).toBe("error"); + if (r.kind === "error") expect(r.message).toMatch(/no refresh token/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/telegram/google-device-flow.test.ts` + +Expected: FAIL with "Cannot find module './google-device-flow.js'". + +- [ ] **Step 3: Write minimal implementation** + +Create `src/telegram/google-device-flow.ts`: + +```ts +export interface DeviceCode { + deviceCode: string; + userCode: string; + verificationUrl: string; + expiresAt: number; + interval: number; +} + +export type CompleteResult = + | { kind: "success"; refreshToken: string } + | { kind: "pending" } + | { kind: "denied" } + | { kind: "expired" } + | { kind: "slow_down" } + | { kind: "error"; message: string }; + +export interface DeviceFlowClient { + start(): Promise; + complete(deviceCode: string): Promise; +} + +const DEVICE_CODE_URL = "https://oauth2.googleapis.com/device/code"; +const TOKEN_URL = "https://oauth2.googleapis.com/token"; +const DEFAULT_SCOPE = "https://www.googleapis.com/auth/calendar"; + +interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_url: string; + expires_in: number; + interval: number; +} + +interface TokenSuccess { + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; +} + +interface TokenError { + error: string; + error_description?: string; +} + +export function createDeviceFlowClient(opts: { + clientId: string; + clientSecret: string; + scope?: string; + fetchFn?: typeof fetch; +}): DeviceFlowClient { + const scope = opts.scope ?? DEFAULT_SCOPE; + const fetchFn = opts.fetchFn ?? fetch; + return { + async start() { + const resp = await fetchFn(DEVICE_CODE_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: opts.clientId, scope }).toString(), + }); + if (!resp.ok) { + throw new Error(`Google device/code failed: ${resp.status} ${await resp.text()}`); + } + const data = (await resp.json()) as DeviceCodeResponse; + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUrl: data.verification_url, + expiresAt: Date.now() + data.expires_in * 1000, + interval: data.interval, + }; + }, + async complete(deviceCode: string) { + const resp = await fetchFn(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: opts.clientId, + client_secret: opts.clientSecret, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }).toString(), + }); + if (resp.ok) { + const data = (await resp.json()) as TokenSuccess; + if (typeof data.refresh_token !== "string" || !data.refresh_token) { + return { + kind: "error", + message: + "Google returned no refresh token. Revoke this app at https://myaccount.google.com/permissions and try /connect_calendar again.", + }; + } + return { kind: "success", refreshToken: data.refresh_token }; + } + if (resp.status !== 400) { + return { kind: "error", message: `Google token endpoint returned HTTP ${resp.status}: ${await resp.text()}` }; + } + const err = (await resp.json()) as TokenError; + switch (err.error) { + case "authorization_pending": + return { kind: "pending" }; + case "slow_down": + return { kind: "slow_down" }; + case "access_denied": + return { kind: "denied" }; + case "expired_token": + return { kind: "expired" }; + default: + return { + kind: "error", + message: err.error_description ? `${err.error}: ${err.error_description}` : err.error, + }; + } + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/telegram/google-device-flow.test.ts` + +Expected: PASS, 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/telegram/google-device-flow.ts src/telegram/google-device-flow.test.ts +git commit -m "Add Google device-authorization flow client" +``` + +--- + +## Task 3: Config change — refresh token becomes optional + +**Files:** +- Modify: `src/config.ts:55-67` +- Test: `src/config.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/config.test.ts` inside the `describe("loadConfig", () => { ... })` block: + +```ts + it("accepts Google client_id + client_secret without a refresh token", () => { + const cfg = loadConfig( + { ...base, GOOGLE_CLIENT_ID: "cid", GOOGLE_CLIENT_SECRET: "csecret" }, + "/p", + ); + expect(cfg.google).toEqual({ clientId: "cid", clientSecret: "csecret", refreshToken: undefined }); + }); + + it("accepts Google client_id + client_secret + refresh_token together", () => { + const cfg = loadConfig( + { + ...base, + GOOGLE_CLIENT_ID: "cid", + GOOGLE_CLIENT_SECRET: "csecret", + GOOGLE_REFRESH_TOKEN: "rt", + }, + "/p", + ); + expect(cfg.google).toEqual({ clientId: "cid", clientSecret: "csecret", refreshToken: "rt" }); + }); + + it("rejects Google config that has client_id without client_secret", () => { + expect(() => + loadConfig({ ...base, GOOGLE_CLIENT_ID: "cid" }, "/p"), + ).toThrow(/GOOGLE_CLIENT_ID.*GOOGLE_CLIENT_SECRET/); + }); + + it("rejects Google config that has client_secret without client_id", () => { + expect(() => + loadConfig({ ...base, GOOGLE_CLIENT_SECRET: "csecret" }, "/p"), + ).toThrow(/GOOGLE_CLIENT_ID.*GOOGLE_CLIENT_SECRET/); + }); + + it("rejects a lone GOOGLE_REFRESH_TOKEN without client_id/secret", () => { + expect(() => + loadConfig({ ...base, GOOGLE_REFRESH_TOKEN: "rt" }, "/p"), + ).toThrow(/GOOGLE_CLIENT_ID.*GOOGLE_CLIENT_SECRET/); + }); + + it("leaves google undefined when no GOOGLE_* vars are set", () => { + expect(loadConfig(base, "/p").google).toBeUndefined(); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/config.test.ts` + +Expected: several failures — the "id + secret without refresh token" case currently throws (spec change) and the `google` object shape doesn't include `refreshToken: undefined` explicitly. + +- [ ] **Step 3: Update the implementation** + +In `src/config.ts`, replace the `GoogleConfig` interface (lines 18-22) and the parsing block (lines 55-67) with: + +```ts +export interface GoogleConfig { + clientId: string; + clientSecret: string; + refreshToken?: string; +} +``` + +and + +```ts + const googleClientId = blankToUndefined(env.GOOGLE_CLIENT_ID); + const googleClientSecret = blankToUndefined(env.GOOGLE_CLIENT_SECRET); + const googleRefreshToken = blankToUndefined(env.GOOGLE_REFRESH_TOKEN); + const hasClientId = Boolean(googleClientId); + const hasClientSecret = Boolean(googleClientSecret); + if (hasClientId !== hasClientSecret || (!hasClientId && googleRefreshToken)) { + throw new ConfigError( + "Google Calendar needs both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (refresh token is optional; if set at startup it will be migrated into the workspace store).", + ); + } + const google: GoogleConfig | undefined = + googleClientId && googleClientSecret + ? { clientId: googleClientId, clientSecret: googleClientSecret, refreshToken: googleRefreshToken } + : undefined; +``` + +- [ ] **Step 4: Run tests to verify all pass** + +Run: `npx vitest run src/config.test.ts` + +Expected: PASS, all cases (existing + 6 new). + +- [ ] **Step 5: Commit** + +```bash +git add src/config.ts src/config.test.ts +git commit -m "Make GOOGLE_REFRESH_TOKEN optional in config" +``` + +--- + +## Task 4: Calendar tool refactor — store-driven access token + +**Files:** +- Modify: `src/tools/calendar.ts:90-118` +- Test: `src/tools/calendar.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Append to `src/tools/calendar.test.ts` (add a new `describe` block at the bottom of the file, inside its existing top-level `describe` or after it — mirror the file's existing style; keep the existing `stubFetch` helper in scope by nesting inside the same file): + +```ts +import { createCalendarTool } from "./calendar.js"; +import type { GoogleTokenStore } from "./google-token-store.js"; + +function memoryStore(initial: string | null): GoogleTokenStore & { current: string | null } { + let current = initial; + return { + read: () => current, + write: (t: string) => { + current = t; + }, + get current() { + return current; + }, + set current(v: string | null) { + current = v; + }, + }; +} + +async function runTool(tool: ReturnType, params: unknown): Promise { + const res = await tool.execute("tc-1", params as never); + return (res.content[0] as { text: string }).text; +} + +describe("createCalendarTool store integration", () => { + let originalFetch: typeof globalThis.fetch; + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns a 'not connected' message when the store has no refresh token", async () => { + globalThis.fetch = vi.fn() as unknown as typeof globalThis.fetch; + const tool = createCalendarTool({ + clientId: "cid", + clientSecret: "csecret", + tokenStore: memoryStore(null), + timezone: "UTC", + }); + const out = await runTool(tool, { action: "list" }); + expect(out).toMatch(/not connected/i); + expect(out).toMatch(/\/connect_calendar/); + }); + + it("mints an access token using the store's refresh token, then reuses the cached one", async () => { + const { fn, calls } = stubFetch([ + { body: { access_token: "at-1", expires_in: 3600 } }, + { body: { items: [] } }, + { body: { items: [] } }, + ]); + globalThis.fetch = fn as unknown as typeof globalThis.fetch; + const tool = createCalendarTool({ + clientId: "cid", + clientSecret: "csecret", + tokenStore: memoryStore("rt-1"), + timezone: "UTC", + }); + await runTool(tool, { action: "list" }); + await runTool(tool, { action: "list" }); + // First call = token refresh, second and third = list events (cached token). + expect(calls[0].url).toBe("https://oauth2.googleapis.com/token"); + expect(calls[1].url).toContain("/calendars/primary/events"); + expect(calls[2].url).toContain("/calendars/primary/events"); + const tokenBody = new URLSearchParams(calls[0].init!.body as string); + expect(tokenBody.get("refresh_token")).toBe("rt-1"); + }); + + it("drops the cached access token when the stored refresh token changes", async () => { + const store = memoryStore("rt-1"); + const { fn, calls } = stubFetch([ + { body: { access_token: "at-1", expires_in: 3600 } }, + { body: { items: [] } }, + { body: { access_token: "at-2", expires_in: 3600 } }, + { body: { items: [] } }, + ]); + globalThis.fetch = fn as unknown as typeof globalThis.fetch; + const tool = createCalendarTool({ + clientId: "cid", + clientSecret: "csecret", + tokenStore: store, + timezone: "UTC", + }); + await runTool(tool, { action: "list" }); + store.current = "rt-2"; + await runTool(tool, { action: "list" }); + // Two refresh calls, two list calls, interleaved. + expect(calls[0].url).toBe("https://oauth2.googleapis.com/token"); + expect(calls[2].url).toBe("https://oauth2.googleapis.com/token"); + const secondRefresh = new URLSearchParams(calls[2].init!.body as string); + expect(secondRefresh.get("refresh_token")).toBe("rt-2"); + }); + + it("invalid_grant returns the /connect_calendar message", async () => { + const { fn } = stubFetch([{ status: 400, body: { error: "invalid_grant" } }]); + globalThis.fetch = fn as unknown as typeof globalThis.fetch; + const tool = createCalendarTool({ + clientId: "cid", + clientSecret: "csecret", + tokenStore: memoryStore("rt-stale"), + timezone: "UTC", + }); + await expect(runTool(tool, { action: "list" })).rejects.toThrow(/\/connect_calendar/); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/tools/calendar.test.ts` + +Expected: `createCalendarTool` doesn't accept the new deps shape → type/build errors and the new suite fails. + +- [ ] **Step 3: Update the implementation** + +In `src/tools/calendar.ts`, replace `createCalendarTool` (lines 90-132) with: + +```ts +export interface CalendarDeps { + clientId: string; + clientSecret: string; + tokenStore: GoogleTokenStore; + timezone: string; +} + +export function createCalendarTool(deps: CalendarDeps) { + let cachedToken: { access: string; expiresAt: number; refreshToken: string } | null = null; + + const getAccessToken = async (): Promise => { + const refreshToken = deps.tokenStore.read(); + if (!refreshToken) { + throw new Error("Google Calendar is not connected. Run /connect_calendar in the bot to link your Google account."); + } + const now = Date.now(); + if ( + cachedToken && + cachedToken.refreshToken === refreshToken && + cachedToken.expiresAt > now + 60_000 + ) { + return cachedToken.access; + } + const resp = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: deps.clientId, + client_secret: deps.clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + if (!resp.ok) { + const text = await resp.text(); + if (text.includes("invalid_grant")) { + throw new Error( + "Google refresh token was rejected (invalid_grant). Run /connect_calendar in the bot to relink.", + ); + } + throw new Error(`Google token refresh failed: ${resp.status} ${text}`); + } + const data = (await resp.json()) as { access_token: string; expires_in: number }; + cachedToken = { + access: data.access_token, + expiresAt: now + data.expires_in * 1000, + refreshToken, + }; + return data.access_token; + }; + + return defineTool({ + name: "calendar", + label: "Calendar", + description: + "Read and modify the owner's Google Calendar: list events, or create, update, or delete an event.", + promptSnippet: "Read and modify the owner's Google Calendar", + parameters: calendarParams, + execute: async (_toolCallId, params) => { + const text = await handleCalendar(params, deps.timezone, getAccessToken); + return { content: [{ type: "text", text }], details: {} }; + }, + }); +} +``` + +Add the missing import near the top of the file (after the existing `import type { GoogleConfig } from "../config.js";` line — you can now remove the `GoogleConfig` import if unused): + +```ts +import type { GoogleTokenStore } from "./google-token-store.js"; +``` + +Remove `import type { GoogleConfig } from "../config.js";` if it becomes unused. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/tools/calendar.test.ts` + +Expected: PASS, existing + 4 new tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/tools/calendar.ts src/tools/calendar.test.ts +git commit -m "Calendar tool reads refresh token from GoogleTokenStore per call" +``` + +--- + +## Task 5: Bridge commands — /connect_calendar and /calendar_done + +**Files:** +- Modify: `src/telegram/bridge.ts:20-40` (deps type), `:33-40` (BOT_COMMANDS), inside `registerBridge` (add command handlers). +- Test: `src/telegram/bridge.test.ts` (extend the harness + add new tests) + +- [ ] **Step 1: Write the failing tests** + +At the top of `src/telegram/bridge.test.ts`, add imports: + +```ts +import type { CompleteResult, DeviceCode, DeviceFlowClient } from "./google-device-flow.js"; +import type { GoogleTokenStore } from "../tools/google-token-store.js"; +``` + +Add helpers below `FakeScheduler` and above `harness`: + +```ts +class FakeDeviceFlow implements DeviceFlowClient { + startResult: DeviceCode | Error = { + deviceCode: "dev-1", + userCode: "ABCD-EFGH", + verificationUrl: "https://google.com/device", + expiresAt: Date.now() + 900_000, + interval: 5, + }; + completeQueue: CompleteResult[] = []; + startCalls = 0; + completeCalls: string[] = []; + async start() { + this.startCalls++; + if (this.startResult instanceof Error) throw this.startResult; + return this.startResult; + } + async complete(deviceCode: string) { + this.completeCalls.push(deviceCode); + const next = this.completeQueue.shift(); + if (!next) throw new Error(`unexpected complete() call with ${deviceCode}`); + return next; + } +} + +class FakeTokenStore implements GoogleTokenStore { + current: string | null = null; + writeError: Error | null = null; + read() { + return this.current; + } + write(t: string) { + if (this.writeError) throw this.writeError; + this.current = t; + } +} +``` + +Extend the `harness()` function to accept and register both new deps. Change: + +```ts +function harness(overrides: { debounceMs?: number; transcriber?: import("../stt/openai.js").Transcriber } = {}) { +``` + +to: + +```ts +function harness(overrides: { + debounceMs?: number; + transcriber?: import("../stt/openai.js").Transcriber; + deviceFlow?: DeviceFlowClient | null; + tokenStore?: GoogleTokenStore | null; +} = {}) { +``` + +and pass them through to `registerBridge` (extend the object literal it builds — see Step 3 below for the exact bridge signature). Return `deviceFlow` and `tokenStore` from `harness` so tests can inspect them: + +```ts + const deviceFlow = overrides.deviceFlow === null ? null : (overrides.deviceFlow ?? new FakeDeviceFlow()); + const tokenStore = overrides.tokenStore === null ? null : (overrides.tokenStore ?? new FakeTokenStore()); + registerBridge({ + bot, + config: { telegramBotToken: "1:TEST", telegramOwnerId: OWNER, workspaceDir: process.cwd(), timezone: "UTC" }, + host, + scheduler, + outbound, + logger, + transcriber: overrides.transcriber, + fetchFn: async () => new Response(new Uint8Array([0x4f, 0x67, 0x67, 0x53])), + debounceMs: overrides.debounceMs ?? 5, + deviceFlow, + tokenStore, + }); + // … + return { bot, calls, host, scheduler, texts, deviceFlow, tokenStore }; +``` + +Add new tests at the end of the `describe("registerBridge", ...)` block: + +```ts + it("/connect_calendar without google config replies 'not configured'", async () => { + const h = harness({ deviceFlow: null, tokenStore: null }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + expect(h.texts()[0]).toMatch(/isn't configured/); + }); + + it("/calendar_done without google config replies 'not configured'", async () => { + const h = harness({ deviceFlow: null, tokenStore: null }); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[0]).toMatch(/isn't configured/); + }); + + it("/connect_calendar shows the URL and code and stores pending state", async () => { + const h = harness(); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + const msg = h.texts()[0]; + expect(msg).toContain("https://google.com/device"); + expect(msg).toContain("ABCD-EFGH"); + expect(msg).toContain("/calendar_done"); + }); + + it("/connect_calendar twice overwrites the pending state — second code wins", async () => { + const flow = new FakeDeviceFlow(); + const h = harness({ deviceFlow: flow }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + flow.startResult = { + deviceCode: "dev-2", + userCode: "WXYZ-1234", + verificationUrl: "https://google.com/device", + expiresAt: Date.now() + 900_000, + interval: 5, + }; + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + flow.completeQueue.push({ kind: "success", refreshToken: "rt-2" }); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(flow.completeCalls).toEqual(["dev-2"]); + expect((h.tokenStore as FakeTokenStore).current).toBe("rt-2"); + }); + + it("/calendar_done with no pending flow replies 'no pending'", async () => { + const h = harness(); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[0]).toMatch(/No pending calendar connection/); + }); + + it("/calendar_done after expiry clears state and asks to restart", async () => { + const flow = new FakeDeviceFlow(); + flow.startResult = { ...flow.startResult as DeviceCode, expiresAt: Date.now() - 1 }; + const h = harness({ deviceFlow: flow }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[1]).toMatch(/expired/); + // A second /calendar_done should now say 'no pending' because state was cleared. + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[2]).toMatch(/No pending calendar connection/); + expect(flow.completeCalls).toEqual([]); + }); + + it("/calendar_done success writes the token and reports connected", async () => { + const flow = new FakeDeviceFlow(); + flow.completeQueue.push({ kind: "success", refreshToken: "rt-good" }); + const h = harness({ deviceFlow: flow }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect((h.tokenStore as FakeTokenStore).current).toBe("rt-good"); + expect(h.texts()[1]).toMatch(/Calendar connected/); + }); + + it("/calendar_done pending keeps state and prompts to approve", async () => { + const flow = new FakeDeviceFlow(); + flow.completeQueue.push({ kind: "pending" }); + flow.completeQueue.push({ kind: "success", refreshToken: "rt-late" }); + const h = harness({ deviceFlow: flow }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[1]).toMatch(/haven't approved/); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect((h.tokenStore as FakeTokenStore).current).toBe("rt-late"); + }); + + it("/calendar_done denied clears state", async () => { + const flow = new FakeDeviceFlow(); + flow.completeQueue.push({ kind: "denied" }); + const h = harness({ deviceFlow: flow }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[1]).toMatch(/denied/); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[2]).toMatch(/No pending calendar connection/); + }); + + it("/calendar_done error keeps state so the owner can retry", async () => { + const flow = new FakeDeviceFlow(); + flow.completeQueue.push({ kind: "error", message: "network broken" }); + flow.completeQueue.push({ kind: "success", refreshToken: "rt-retry" }); + const h = harness({ deviceFlow: flow }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[1]).toMatch(/network broken/); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect((h.tokenStore as FakeTokenStore).current).toBe("rt-retry"); + }); + + it("/calendar_done reports a save failure and clears state", async () => { + const flow = new FakeDeviceFlow(); + flow.completeQueue.push({ kind: "success", refreshToken: "rt-drop" }); + const store = new FakeTokenStore(); + store.writeError = new Error("disk full"); + const h = harness({ deviceFlow: flow, tokenStore: store }); + await h.bot.handleUpdate(textUpdate("/connect_calendar")); + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[1]).toMatch(/failed to save/); + // State should be cleared: next /calendar_done says no pending. + await h.bot.handleUpdate(textUpdate("/calendar_done")); + expect(h.texts()[2]).toMatch(/No pending calendar connection/); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/telegram/bridge.test.ts` + +Expected: type errors (bridge doesn't accept `deviceFlow` / `tokenStore` yet) and every new test fails. + +- [ ] **Step 3: Update the bridge implementation** + +In `src/telegram/bridge.ts`, add imports at the top: + +```ts +import type { GoogleTokenStore } from "../tools/google-token-store.js"; +import type { DeviceCode, DeviceFlowClient } from "./google-device-flow.js"; +``` + +Extend `BridgeDeps` (line 20): + +```ts +export interface BridgeDeps { + bot: Bot; + config: Pick; + host: HostLike; + scheduler: SchedulerLike; + outbound: Outbound; + transcriber?: Transcriber; + logger: Logger; + fetchFn?: typeof fetch; + /** Debounce window for merging rapid-succession messages (e.g. forward + comment). Defaults to 600ms. */ + debounceMs?: number; + /** Google device-authorization flow client. Null when GOOGLE_CLIENT_ID/SECRET aren't set. */ + deviceFlow: DeviceFlowClient | null; + /** Refresh-token store. Null when GOOGLE_CLIENT_ID/SECRET aren't set. */ + tokenStore: GoogleTokenStore | null; +} +``` + +Extend `BOT_COMMANDS` (line 33): + +```ts +export const BOT_COMMANDS = [ + { command: "status", description: "Show agent status" }, + { command: "new", description: "Start a fresh session" }, + { command: "stop", description: "Abort the current run" }, + { command: "jobs", description: "List scheduled jobs" }, + { command: "cancel", description: "Cancel a job: /cancel " }, + { command: "compact", description: "Compact the conversation context" }, + { command: "connect_calendar", description: "Link your Google Calendar" }, + { command: "calendar_done", description: "Finish linking Google Calendar" }, +]; +``` + +Inside `registerBridge`, above `bot.on("message", ...)` add the two commands. Also declare `pendingDevice` at the top of the function (single-owner bot, so a plain closure variable is enough): + +```ts + let pendingDevice: DeviceCode | null = null; + + bot.command("connect_calendar", async (ctx) => { + if (!deps.deviceFlow) { + await ctx.reply( + "Google Calendar isn't configured on this bot. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the environment first.", + ); + return; + } + try { + const code = await deps.deviceFlow.start(); + pendingDevice = code; + const mins = Math.max(1, Math.round((code.expiresAt - Date.now()) / 60_000)); + await ctx.reply( + `Go to ${code.verificationUrl} and enter code ${code.userCode}.\n\n` + + `When you've approved it there, send /calendar_done here and I'll finish the connection.\n\n` + + `Code expires in ${mins} minute${mins === 1 ? "" : "s"}.`, + ); + } catch (err) { + await ctx.reply(`Couldn't start Google device flow: ${errorText(err)}`); + } + }); + + bot.command("calendar_done", async (ctx) => { + if (!deps.deviceFlow || !deps.tokenStore) { + await ctx.reply( + "Google Calendar isn't configured on this bot. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the environment first.", + ); + return; + } + if (!pendingDevice) { + await ctx.reply("No pending calendar connection. Run /connect_calendar first."); + return; + } + if (pendingDevice.expiresAt <= Date.now()) { + pendingDevice = null; + await ctx.reply("That code expired. Run /connect_calendar to start over."); + return; + } + const active = pendingDevice; + const result = await deps.deviceFlow.complete(active.deviceCode); + switch (result.kind) { + case "success": { + try { + deps.tokenStore.write(result.refreshToken); + } catch (err) { + pendingDevice = null; + await ctx.reply(`Got a refresh token but failed to save it: ${errorText(err)}`); + return; + } + pendingDevice = null; + await ctx.reply("✅ Calendar connected."); + return; + } + case "pending": + case "slow_down": + await ctx.reply( + `Google says you haven't approved it yet. Approve at ${active.verificationUrl} (code ${active.userCode}), then send /calendar_done again.`, + ); + return; + case "denied": + pendingDevice = null; + await ctx.reply("You denied the request. Run /connect_calendar to try again."); + return; + case "expired": + pendingDevice = null; + await ctx.reply("That code expired. Run /connect_calendar to start over."); + return; + case "error": + await ctx.reply(`Couldn't finish Google connection: ${result.message}`); + return; + } + }); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/telegram/bridge.test.ts` + +Expected: PASS, all existing + 11 new tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/telegram/bridge.ts src/telegram/bridge.test.ts +git commit -m "Add /connect_calendar and /calendar_done bot commands" +``` + +--- + +## Task 6: Wire everything together in main.ts + +**Files:** +- Modify: `src/main.ts:13-16` (imports), `:86-91` (Google wiring), `:128` (registerBridge call). + +- [ ] **Step 1: Update main.ts imports** + +Add to the imports at the top of `src/main.ts`: + +```ts +import { createDeviceFlowClient, type DeviceFlowClient } from "./telegram/google-device-flow.js"; +import { createGoogleTokenStore, type GoogleTokenStore } from "./tools/google-token-store.js"; +``` + +- [ ] **Step 2: Replace the Google wiring block** + +Replace lines 86-91 (the current `if (config.google) { ... } else { ... }` block) with: + +```ts + let deviceFlow: DeviceFlowClient | null = null; + let tokenStore: GoogleTokenStore | null = null; + if (config.google) { + tokenStore = createGoogleTokenStore(config.workspaceDir); + if (config.google.refreshToken && tokenStore.read() === null) { + tokenStore.write(config.google.refreshToken); + logger.warn( + "GOOGLE_REFRESH_TOKEN env var seeded into workspace/.secretary/google-token.json. " + + "You can remove GOOGLE_REFRESH_TOKEN from .env now — future updates come through /connect_calendar.", + ); + } + customTools.push(createCalendarTool({ + clientId: config.google.clientId, + clientSecret: config.google.clientSecret, + tokenStore, + timezone: config.timezone, + })); + logger.info("calendar tool enabled"); + deviceFlow = createDeviceFlowClient({ + clientId: config.google.clientId, + clientSecret: config.google.clientSecret, + }); + } else { + logger.info("calendar tool disabled (set GOOGLE_CLIENT_ID/SECRET to enable)"); + } +``` + +- [ ] **Step 3: Pass the new deps into registerBridge** + +Replace the `registerBridge(...)` call (line 128) with: + +```ts + registerBridge({ bot, config, host, scheduler, outbound, transcriber, logger, deviceFlow, tokenStore }); +``` + +- [ ] **Step 4: Run the full test suite to check nothing broke** + +Run: `npm test` + +Expected: PASS, all tests. Type checking + all vitest cases green. + +- [ ] **Step 5: Verify a fresh build still succeeds** + +Run: `npm run build` + +Expected: `dist/` produced, no TypeScript errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/main.ts +git commit -m "Wire GoogleTokenStore and DeviceFlowClient into main" +``` + +--- + +## Task 7: Update .env.example and README + +**Files:** +- Modify: `.env.example:18-23` +- Modify: `README.md` — the Google line under `## Environment variables`, plus the commands line, plus a new E2E item. + +- [ ] **Step 1: Rewrite the Google block in `.env.example`** + +Replace lines 18-23 of `.env.example` with: + +``` +# Google Calendar tool. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET from a +# Google Cloud OAuth client of type "TVs and Limited Input devices" (scope: +# https://www.googleapis.com/auth/calendar). Then in Telegram, run +# /connect_calendar and follow the two-step flow — no refresh token needed here. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +# Deprecated. If set at startup, seeded once into workspace/.secretary/google-token.json +# and then ignored. Use /connect_calendar in the bot for new setups. +GOOGLE_REFRESH_TOKEN= +``` + +- [ ] **Step 2: Update README commands line** + +In `README.md`, replace the "Commands:" line (currently line 27) with: + +``` +Commands: `/status`, `/new`, `/stop`, `/jobs`, `/cancel `, `/compact`, `/connect_calendar`, `/calendar_done`. +``` + +- [ ] **Step 3: Add a Google Calendar section to README** + +After the "Scheduling" section (around line 40) and before "Workspace" (line 42), insert: + +```md +## Google Calendar + +The bot can read and write the owner's Google Calendar. Setup is one-time in Google Cloud, then two commands in the bot. + +1. In Google Cloud Console, enable the **Google Calendar API** and create an **OAuth 2.0 client of type "TVs and Limited Input devices"**. +2. Put the client id and secret in `.env` as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Restart the bot. +3. In Telegram, send `/connect_calendar`. The bot replies with a `https://google.com/device` link and a short code. +4. Open the link, sign in, enter the code, approve. Then send `/calendar_done` back to the bot. It replies `✅ Calendar connected.` + +The refresh token is stored at `workspace/.secretary/google-token.json`. Delete that file to disconnect; run `/connect_calendar` again to relink. +``` + +- [ ] **Step 4: Update the Google row in the env-vars table** + +In `README.md`, the environment-variables table currently doesn't mention the Google vars. Add three rows to the table (after the `PLAYWRIGHT_EXTRA_ARGS` row): + +``` +| `GOOGLE_CLIENT_ID` | no | | OAuth client id (TVs and Limited Input devices) — enables the calendar tool | +| `GOOGLE_CLIENT_SECRET` | no | | OAuth client secret — required alongside `GOOGLE_CLIENT_ID` | +| `GOOGLE_REFRESH_TOKEN` | no | | Deprecated. If set, seeded once into `workspace/.secretary/google-token.json`, then ignored. | +``` + +- [ ] **Step 5: Add manual E2E step #21** + +In `README.md`, append after the current step 20: + +``` +21. Delete `workspace/.secretary/google-token.json` if it exists. Ask "what's on my calendar tomorrow?" → tool replies with a "not connected" message referencing `/connect_calendar`. Run `/connect_calendar` → bot replies with a `https://google.com/device` URL and a short code. Approve on that page, then `/calendar_done` → bot replies `✅ Calendar connected.` and the token file now exists. Ask the same calendar question again → the tool answers. +``` + +- [ ] **Step 6: Commit** + +```bash +git add .env.example README.md +git commit -m "Document the /connect_calendar flow in README and .env.example" +``` + +--- + +## Task 8: End-to-end sanity — full build + tests + +- [ ] **Step 1: Run everything one last time** + +Run: `npm test && npm run build` + +Expected: all tests pass and `dist/` builds cleanly. If anything fails, fix it before moving on — do not commit skipped tests. + +- [ ] **Step 2: Confirm no leftover references** + +Run: `grep -rn "GOOGLE_REFRESH_TOKEN" src/ docker-compose.yml` — should show only: +- `docker-compose.yml` (still passing the env var through — deprecated but harmless). +- No test file should assert the old behaviour of "throws when refresh token missing". + +Run: `grep -rn "device-flow bootstrap" src/` — should return nothing (the old error message referencing the bootstrap script is gone; the new one references `/connect_calendar`). + +- [ ] **Step 3: Manual smoke via the E2E step** + +Only after unit tests are green: follow README manual step #21 against a real Telegram bot with a real Google client id/secret. Verify the token file appears at `workspace/.secretary/google-token.json` and a real calendar question works after connecting. + +If you don't have Telegram + Google credentials handy at this point, log that the manual step was deferred and hand back to the user. + +- [ ] **Step 4: No commit needed here** (this task is verification, not code changes). + +--- + +## Rollback + +Every task commits independently. If a task fails after commit, `git revert ` restores the prior state. The only cross-task change is the `BridgeDeps` interface addition in Task 5 — if that's reverted, the corresponding `registerBridge(...)` call in `main.ts` (Task 6) needs to be reverted too. + +--- + +## Self-review + +**Spec coverage:** + +- ✅ Token store (Task 1) — matches spec's TokenStore + storage location + mode 0600. +- ✅ Device-flow client (Task 2) — matches all six result kinds + the "no refresh_token in success body" edge case. +- ✅ Config change (Task 3) — refresh token optional, "id-alone" rejected, lone refresh token rejected. +- ✅ Calendar tool refactor (Task 4) — reads store per call, cache-invalidation on refresh-token rotation, updated `invalid_grant` message. +- ✅ Bridge commands (Task 5) — `/connect_calendar`, `/calendar_done`, all documented state transitions covered by tests. +- ✅ Main.ts wiring (Task 6) — env-var migration on first boot, both deps threaded into bridge and tool. +- ✅ Docs (Task 7) — `.env.example`, README section, commands list, env-vars table, E2E step #21. + +**Placeholders:** none. Every step shows the actual code, test, or command. + +**Type consistency:** `GoogleTokenStore` interface is defined once (Task 1), used by Tasks 4, 5, 6. `DeviceFlowClient` / `CompleteResult` / `DeviceCode` defined in Task 2, used in Tasks 5 and 6. `CalendarDeps` defined in Task 4, used in Task 6. `BridgeDeps` gains `deviceFlow` / `tokenStore` in Task 5 and is populated in Task 6.