From ccd6a4b45a698f5f38f7835cc22325110530aedd Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Thu, 13 Aug 2026 18:50:25 +0300 Subject: [PATCH] feat(calendar): per-provider credentials and settings namespace (#1394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credentials and settings were both nailed to Google by a constant: `SERVICE = 'com.memry.calendar.google'` and the literal settings group key `'calendar.google'`. **Keychain.** `calendar/provider/credentials.ts` derives the service name per provider — `com.memry.calendar.` — which for google produces the exact string the constant held, so every credential already on disk still resolves. The account-key scheme (`${kind}-${accountId}` plus the dev-profile suffix) and the `LEGACY_DEFAULT_ACCOUNT_ID` path are preserved verbatim. `GoogleTokenKind` becomes `ProviderSecretKind = 'access-token' | 'refresh-token' | 'password'`; CalDAV needs the password, ICS has no secret at all. **Settings.** `calendar.` groups in a shared neutral shape (`CalendarProviderSettings`). Google's group is frozen exactly as it is — same key, same keys, `pushEventsToGoogle` included — and `readCalendarProviderSettings` translates that one historic name to `pushEventsToProvider` so the sync engine only ever sees the neutral form. No migration; a new provider only ever writes a new key. **Agent read consent — decision.** `agentReadEventsConsent` is promoted from a Google special case to the base schema, with per-provider values. No provider's external events are readable by the agent until that provider is explicitly consented to; null (never asked) reads as no. Google Workspace Limited Use forced the question first, and Google's behavior is now an instance of the rule rather than an exception to it. The renderer gate follows: `calendar.getRange` resolves `includeExternal` by requiring consent for every registered provider, since the call carries one flag rather than a per-provider list. One unconsented provider keeps every external event out rather than risking one leaking through. Documented under `apps/docs/src/user-guide/calendar.md` and `apps/docs/src/user-guide/ai/agent-mcp.md`. --- .../calendar/provider/credentials.test.ts | 202 ++++++++++++++++++ .../src/main/calendar/provider/credentials.ts | 101 +++++++++ .../main/calendar/provider/settings.test.ts | 152 +++++++++++++ .../src/main/calendar/provider/settings.ts | 92 ++++++++ .../calendar/providers/google/keychain.ts | 76 +++---- .../calendar/providers/google/sync-service.ts | 7 +- .../src/main/ipc/generated-ipc-invoke-map.ts | 4 +- .../src/main/ipc/settings-handlers.test.ts | 112 ++++++++++ .../desktop/src/main/ipc/settings-handlers.ts | 42 ++-- .../agent-mcp/desktop-api-handler.test.tsx | 23 +- .../src/agent-mcp/desktop-api-handler.ts | 28 ++- apps/docs/src/user-guide/ai/agent-mcp.md | 19 +- apps/docs/src/user-guide/calendar.md | 24 ++- packages/contracts/src/settings-schemas.ts | 38 ++++ packages/rpc/src/settings.ts | 5 +- 15 files changed, 827 insertions(+), 98 deletions(-) create mode 100644 apps/desktop/src/main/calendar/provider/credentials.test.ts create mode 100644 apps/desktop/src/main/calendar/provider/credentials.ts create mode 100644 apps/desktop/src/main/calendar/provider/settings.test.ts create mode 100644 apps/desktop/src/main/calendar/provider/settings.ts diff --git a/apps/desktop/src/main/calendar/provider/credentials.test.ts b/apps/desktop/src/main/calendar/provider/credentials.test.ts new file mode 100644 index 000000000..a6bbd49cb --- /dev/null +++ b/apps/desktop/src/main/calendar/provider/credentials.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import keytar from 'keytar' + +vi.mock('keytar', () => ({ + default: { + setPassword: vi.fn(), + getPassword: vi.fn(), + deletePassword: vi.fn() + } +})) + +import { + calendarCredentialService, + deleteProviderSecret, + getProviderAccountKey, + getProviderSecret, + setProviderSecret +} from './credentials' +import { LEGACY_DEFAULT_ACCOUNT_ID, getGoogleCalendarTokens } from '../providers/google/keychain' + +describe('per-provider calendar credentials (#1394)', () => { + const keytarStore = new Map() + + beforeEach(() => { + vi.clearAllMocks() + keytarStore.clear() + delete process.env.MEMRY_DEVICE + + vi.mocked(keytar.setPassword).mockImplementation(async (service, account, value) => { + keytarStore.set(`${service}:${account}`, value) + }) + vi.mocked(keytar.getPassword).mockImplementation( + async (service, account) => keytarStore.get(`${service}:${account}`) ?? null + ) + vi.mocked(keytar.deletePassword).mockImplementation(async (service, account) => + keytarStore.delete(`${service}:${account}`) + ) + }) + + afterEach(() => { + delete process.env.MEMRY_DEVICE + }) + + it('gives google the exact service name the Google-only build hard-coded', () => { + // A different string here strands every credential already on disk. + expect(calendarCredentialService('google')).toBe('com.memry.calendar.google') + expect(calendarCredentialService('caldav')).toBe('com.memry.calendar.caldav') + expect(calendarCredentialService('microsoft')).toBe('com.memry.calendar.microsoft') + }) + + it('preserves the account-key scheme, dev-profile suffix included', () => { + expect(getProviderAccountKey('alice@example.com', 'refresh-token')).toBe( + 'refresh-token-alice@example.com' + ) + + process.env.MEMRY_DEVICE = 'b' + expect(getProviderAccountKey('alice@example.com', 'refresh-token')).toBe( + 'refresh-token-alice@example.com-b' + ) + }) + + it('rejects an empty provider id or account id rather than writing a bare key', async () => { + expect(() => calendarCredentialService('')).toThrow(/non-empty providerId/) + expect(() => getProviderAccountKey(' ', 'password')).toThrow(/non-empty accountId/) + }) + + it('resolves a credential a previous Google-only build wrote', async () => { + // #given the exact keytar entry the old code path produced + keytarStore.set('com.memry.calendar.google:refresh-token-alice@example.com', 'old-refresh') + + // #then both the neutral reader and the Google accessor find it + expect( + await getProviderSecret({ + providerId: 'google', + accountId: 'alice@example.com', + kind: 'refresh-token' + }) + ).toBe('old-refresh') + expect(await getGoogleCalendarTokens('alice@example.com')).toEqual({ + accessToken: null, + refreshToken: 'old-refresh' + }) + }) + + it('resolves a pre-multi-account credential stored under the legacy account id', async () => { + keytarStore.set( + `com.memry.calendar.google:refresh-token-${LEGACY_DEFAULT_ACCOUNT_ID}`, + 'legacy-refresh' + ) + + expect(await getGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)).toEqual({ + accessToken: null, + refreshToken: 'legacy-refresh' + }) + }) + + it('partitions two providers that share an account id', async () => { + await setProviderSecret({ + providerId: 'google', + accountId: 'alice@example.com', + kind: 'refresh-token', + value: 'google-refresh' + }) + await setProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password', + value: 'app-password' + }) + + expect( + await getProviderSecret({ + providerId: 'google', + accountId: 'alice@example.com', + kind: 'refresh-token' + }) + ).toBe('google-refresh') + expect( + await getProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password' + }) + ).toBe('app-password') + + // Disconnecting CalDAV must not log the user out of Google. + await deleteProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password' + }) + expect( + await getProviderSecret({ + providerId: 'google', + accountId: 'alice@example.com', + kind: 'refresh-token' + }) + ).toBe('google-refresh') + }) + + it('treats an empty value as a delete rather than storing a blank secret', async () => { + await setProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password', + value: 'app-password' + }) + await setProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password', + value: ' ' + }) + + expect( + await getProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password' + }) + ).toBeNull() + }) + describe('keychain failures name the provider', () => { + it('wraps a write failure rather than surfacing a bare keytar error', async () => { + vi.mocked(keytar.setPassword).mockRejectedValueOnce(new Error('keychain locked')) + + await expect( + setProviderSecret({ + providerId: 'caldav', + accountId: 'alice@example.com', + kind: 'password', + value: 'app-password' + }) + ).rejects.toThrow(/Failed to store caldav calendar credential .*keychain locked/) + }) + + it('wraps a delete failure the same way', async () => { + vi.mocked(keytar.deletePassword).mockRejectedValueOnce(new Error('keychain locked')) + + await expect( + deleteProviderSecret({ + providerId: 'google', + accountId: 'alice@example.com', + kind: 'refresh-token' + }) + ).rejects.toThrow(/Failed to delete google calendar credential/) + }) + + it('reports a non-Error rejection as an unknown error rather than "undefined"', async () => { + vi.mocked(keytar.setPassword).mockRejectedValueOnce('nope') + + await expect( + setProviderSecret({ + providerId: 'ics', + accountId: 'alice@example.com', + kind: 'password', + value: 'x' + }) + ).rejects.toThrow(/unknown error/) + }) + }) +}) diff --git a/apps/desktop/src/main/calendar/provider/credentials.ts b/apps/desktop/src/main/calendar/provider/credentials.ts new file mode 100644 index 000000000..7741ab80e --- /dev/null +++ b/apps/desktop/src/main/calendar/provider/credentials.ts @@ -0,0 +1,101 @@ +import { deleteSecret, getSecret, setSecret } from '../../secrets/secret-storage' + +/** + * What a calendar provider can need to store. + * + * - `access-token` / `refresh-token` — OAuth2 (Google, Microsoft Graph) + * - `password` — HTTP Basic / app password (CalDAV: Fastmail, Nextcloud, iCloud) + * + * ICS feeds have no secret at all; the URL itself is the credential and lives + * in `calendar_sources.metadata`, not the keychain. + */ +export type ProviderSecretKind = 'access-token' | 'refresh-token' | 'password' + +/** + * One keychain service per provider. `google` resolves to + * `com.memry.calendar.google`, byte-identical to the constant it replaces, so + * an existing install's stored tokens keep resolving. + */ +export function calendarCredentialService(providerId: string): string { + if (!providerId || !providerId.trim()) { + throw new Error('calendarCredentialService requires a non-empty providerId') + } + return `com.memry.calendar.${providerId}` +} + +/** + * The account slot inside a provider's service. Scheme preserved verbatim from + * the Google-only era — `${kind}-${accountId}`, with the dev-profile suffix — + * because changing it would strand every credential already on disk. + */ +export function getProviderAccountKey(accountId: string, kind: ProviderSecretKind): string { + if (!accountId || !accountId.trim()) { + throw new Error('getProviderAccountKey requires a non-empty accountId') + } + const deviceSuffix = process.env.MEMRY_DEVICE + const base = `${kind}-${accountId}` + return deviceSuffix ? `${base}-${deviceSuffix}` : base +} + +export async function setProviderSecret(input: { + providerId: string + accountId: string + kind: ProviderSecretKind + value: string | null +}): Promise { + const service = calendarCredentialService(input.providerId) + const account = getProviderAccountKey(input.accountId, input.kind) + + try { + if (!input.value || input.value.trim().length === 0) { + await deleteSecret(service, account) + return + } + await setSecret(service, account, input.value.trim()) + } catch (error) { + throw new Error( + `Failed to store ${input.providerId} calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` + ) + } +} + +export async function getProviderSecret(input: { + providerId: string + accountId: string + kind: ProviderSecretKind +}): Promise { + const service = calendarCredentialService(input.providerId) + const account = getProviderAccountKey(input.accountId, input.kind) + + try { + // Every consumer of these secrets either overwrites them (the connect flow + // and the refresh path), deletes them (disconnect), or only asks whether + // the account is still authorized. So an entry we cannot decrypt — the + // profiles stranded by the v2026-08-06 app-identity rename — must read as + // absent rather than throw, otherwise the pre-write read kills the connect + // flow before the fresh credential is ever stored and the account can never + // be reconnected from inside the app. + return await getSecret(service, account, { treatUnreadableAsAbsent: true }) + } catch (error) { + throw new Error( + `Failed to read ${input.providerId} calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` + ) + } +} + +export async function deleteProviderSecret(input: { + providerId: string + accountId: string + kind: ProviderSecretKind +}): Promise { + const service = calendarCredentialService(input.providerId) + const account = getProviderAccountKey(input.accountId, input.kind) + + try { + await deleteSecret(service, account) + } catch (error) { + throw new Error( + `Failed to delete ${input.providerId} calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` + ) + } +} diff --git a/apps/desktop/src/main/calendar/provider/settings.test.ts b/apps/desktop/src/main/calendar/provider/settings.test.ts new file mode 100644 index 000000000..915f6f8cf --- /dev/null +++ b/apps/desktop/src/main/calendar/provider/settings.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, afterEach, describe, expect, it } from 'vitest' +import { settings } from '@memry/db-schema/schema/settings' +import { + CalendarGoogleSettingsSchema, + CalendarProviderSettingsSchema +} from '@memry/contracts/settings-schemas' +import { + asClientDb, + createTestDataDb, + type TestDatabaseResult, + type TestDb +} from '@tests/utils/test-db' +import { getSetting } from '../../settings/settings-store' +import { + calendarProviderSettingsKey, + hasAgentReadConsent, + readCalendarProviderSettings, + writeCalendarProviderSettings +} from './settings' + +/** + * The exact JSON an install running the Google-only build wrote to + * `settings['calendar.google']`. Nothing here may need a migration. + */ +const LEGACY_GOOGLE_ROW = { + defaultTargetCalendarId: 'work@group.calendar.google.com', + onboardingCompleted: true, + promoteConfirmDismissed: true, + pushEventsToGoogle: false, + agentReadEventsConsent: true +} + +/** Written before `agentReadEventsConsent` shipped — the key is simply absent. */ +const OLDER_GOOGLE_ROW = { + defaultTargetCalendarId: null, + onboardingCompleted: true, + promoteConfirmDismissed: false, + pushEventsToGoogle: true +} + +describe('per-provider calendar settings (#1394)', () => { + let dbResult: TestDatabaseResult + let db: TestDb + + beforeEach(() => { + dbResult = createTestDataDb() + db = dbResult.db + }) + + afterEach(() => { + dbResult.close() + }) + + function seedGroup(key: string, value: unknown): void { + db.insert(settings) + .values({ key, value: JSON.stringify(value), modifiedAt: '2026-04-12T09:00:00.000Z' }) + .run() + } + + describe('the google group is frozen in its historic shape', () => { + it('parses a settings fixture written by the Google-only build, unchanged', () => { + expect(CalendarGoogleSettingsSchema.safeParse(LEGACY_GOOGLE_ROW).success).toBe(true) + expect(CalendarGoogleSettingsSchema.safeParse(OLDER_GOOGLE_ROW).success).toBe(true) + }) + + it('reads that fixture back through the neutral accessor', () => { + seedGroup('calendar.google', LEGACY_GOOGLE_ROW) + + expect(readCalendarProviderSettings(asClientDb(db), 'google')).toEqual({ + defaultTargetCalendarId: 'work@group.calendar.google.com', + onboardingCompleted: true, + promoteConfirmDismissed: true, + // The one translated key: stored as pushEventsToGoogle, read as neutral. + pushEventsToProvider: false, + agentReadEventsConsent: true + }) + }) + + it('defaults a pre-consent row to "not asked", not to allowed', () => { + seedGroup('calendar.google', OLDER_GOOGLE_ROW) + + const read = readCalendarProviderSettings(asClientDb(db), 'google') + expect(read.agentReadEventsConsent).toBeNull() + expect(hasAgentReadConsent(asClientDb(db), 'google')).toBe(false) + }) + + it('writes back the legacy key and adds no new ones', () => { + seedGroup('calendar.google', LEGACY_GOOGLE_ROW) + + writeCalendarProviderSettings(asClientDb(db), 'google', { pushEventsToProvider: true }) + + const raw = JSON.parse(getSetting(asClientDb(db), 'calendar.google') ?? '{}') as Record< + string, + unknown + > + expect(raw.pushEventsToGoogle).toBe(true) + expect(raw).not.toHaveProperty('pushEventsToProvider') + expect(Object.keys(raw).sort()).toEqual(Object.keys(LEGACY_GOOGLE_ROW).sort()) + }) + + it('keeps the historic group key', () => { + expect(calendarProviderSettingsKey('google')).toBe('calendar.google') + }) + }) + + describe('a new provider gets its own group in the neutral shape', () => { + it('stores under calendar. and never touches google', () => { + seedGroup('calendar.google', LEGACY_GOOGLE_ROW) + + writeCalendarProviderSettings(asClientDb(db), 'caldav', { + defaultTargetCalendarId: 'https://caldav.fastmail.com/personal', + pushEventsToProvider: false + }) + + const caldav = readCalendarProviderSettings(asClientDb(db), 'caldav') + expect(caldav.defaultTargetCalendarId).toBe('https://caldav.fastmail.com/personal') + expect(caldav.pushEventsToProvider).toBe(false) + expect(CalendarProviderSettingsSchema.safeParse(caldav).success).toBe(true) + + // Google's row is untouched — no migration, no cross-writes. + expect(JSON.parse(getSetting(asClientDb(db), 'calendar.google') ?? '{}')).toEqual( + LEGACY_GOOGLE_ROW + ) + }) + + it('starts every provider at "agent may not read", including ones never written', () => { + expect(hasAgentReadConsent(asClientDb(db), 'ics')).toBe(false) + expect(hasAgentReadConsent(asClientDb(db), 'caldav')).toBe(false) + + writeCalendarProviderSettings(asClientDb(db), 'ics', { agentReadEventsConsent: false }) + expect(hasAgentReadConsent(asClientDb(db), 'ics')).toBe(false) + + writeCalendarProviderSettings(asClientDb(db), 'ics', { agentReadEventsConsent: true }) + expect(hasAgentReadConsent(asClientDb(db), 'ics')).toBe(true) + // Consenting to one provider says nothing about another. + expect(hasAgentReadConsent(asClientDb(db), 'caldav')).toBe(false) + }) + + it('falls back to defaults when the stored group is corrupt', () => { + db.insert(settings) + .values({ + key: 'calendar.caldav', + value: 'not json', + modifiedAt: '2026-04-12T09:00:00.000Z' + }) + .run() + + expect(readCalendarProviderSettings(asClientDb(db), 'caldav').pushEventsToProvider).toBe(true) + expect(hasAgentReadConsent(asClientDb(db), 'caldav')).toBe(false) + }) + }) +}) diff --git a/apps/desktop/src/main/calendar/provider/settings.ts b/apps/desktop/src/main/calendar/provider/settings.ts new file mode 100644 index 000000000..75cdd941c --- /dev/null +++ b/apps/desktop/src/main/calendar/provider/settings.ts @@ -0,0 +1,92 @@ +import { + CALENDAR_GOOGLE_SETTINGS_DEFAULTS, + CALENDAR_PROVIDER_SETTINGS_DEFAULTS, + type CalendarGoogleSettings, + type CalendarProviderSettings +} from '@memry/contracts/settings-schemas' +import { getSetting, setSetting } from '../../settings/settings-store' +import type { DataDb } from '../../database/types' + +export const GOOGLE_LEGACY_SETTINGS_PROVIDER_ID = 'google' + +export function calendarProviderSettingsKey(providerId: string): string { + return `calendar.${providerId}` +} + +function readGroup(db: DataDb, key: string, defaults: T): T { + const raw = getSetting(db, key) + if (!raw) return { ...defaults } + try { + const parsed = JSON.parse(raw) as Partial + return { ...defaults, ...parsed } + } catch { + return { ...defaults } + } +} + +/** + * Read one provider's settings in the neutral shape. + * + * Google is the one exception, and it is a naming exception only: its group was + * written before there was a second provider, so its outbound toggle is stored + * as `pushEventsToGoogle`. The row is read and written in that exact shape — + * no migration, no extra keys — and translated here so callers only ever see + * `pushEventsToProvider`. + */ +export function readCalendarProviderSettings( + db: DataDb, + providerId: string +): CalendarProviderSettings { + if (providerId === GOOGLE_LEGACY_SETTINGS_PROVIDER_ID) { + const google = readGroup( + db, + calendarProviderSettingsKey(providerId), + CALENDAR_GOOGLE_SETTINGS_DEFAULTS + ) + return { + defaultTargetCalendarId: google.defaultTargetCalendarId, + onboardingCompleted: google.onboardingCompleted, + promoteConfirmDismissed: google.promoteConfirmDismissed, + pushEventsToProvider: google.pushEventsToGoogle, + agentReadEventsConsent: google.agentReadEventsConsent + } + } + + return readGroup(db, calendarProviderSettingsKey(providerId), CALENDAR_PROVIDER_SETTINGS_DEFAULTS) +} + +/** Merge a partial update into one provider's group, from inside the main process. */ +export function writeCalendarProviderSettings( + db: DataDb, + providerId: string, + updates: Partial +): void { + const key = calendarProviderSettingsKey(providerId) + + if (providerId === GOOGLE_LEGACY_SETTINGS_PROVIDER_ID) { + const current = readGroup(db, key, CALENDAR_GOOGLE_SETTINGS_DEFAULTS) + const { pushEventsToProvider, ...neutral } = updates + const next: CalendarGoogleSettings = { + ...current, + ...neutral, + ...(pushEventsToProvider === undefined ? {} : { pushEventsToGoogle: pushEventsToProvider }) + } + setSetting(db, key, JSON.stringify(next)) + return + } + + const current = readGroup(db, key, CALENDAR_PROVIDER_SETTINGS_DEFAULTS) + setSetting(db, key, JSON.stringify({ ...current, ...updates })) +} + +/** + * Whether the AI agent may read this provider's external events. + * + * The rule is provider-neutral: nothing other than a stored `true` opens the + * gate. `null` — never asked — reads as no, exactly like an explicit refusal. + * Google Workspace Limited Use is what forced the question first; the answer + * became the house rule for every provider. + */ +export function hasAgentReadConsent(db: DataDb, providerId: string): boolean { + return readCalendarProviderSettings(db, providerId).agentReadEventsConsent === true +} diff --git a/apps/desktop/src/main/calendar/providers/google/keychain.ts b/apps/desktop/src/main/calendar/providers/google/keychain.ts index 507fc1943..718a1099f 100644 --- a/apps/desktop/src/main/calendar/providers/google/keychain.ts +++ b/apps/desktop/src/main/calendar/providers/google/keychain.ts @@ -1,18 +1,30 @@ -import { deleteSecret, getSecret, setSecret } from '../../../secrets/secret-storage' - -const SERVICE = 'com.memry.calendar.google' - +import { + deleteProviderSecret, + getProviderAccountKey, + getProviderSecret, + setProviderSecret, + type ProviderSecretKind +} from '../../provider/credentials' +import { GOOGLE_PROVIDER_ID } from './capabilities' + +/** + * Google's slot in the per-provider keychain. The service name resolves to + * `com.memry.calendar.google` — the exact string this file used to hard-code — + * and the account-key scheme is unchanged, so every credential already on disk + * still resolves. No migration. + */ + +/** + * The account id used before multi-account support existed. Still read by the + * OAuth layer when it finds a stored credential with no account attached. + */ export const LEGACY_DEFAULT_ACCOUNT_ID = '__memry_default__' -export type GoogleTokenKind = 'access-token' | 'refresh-token' +/** Google only ever stores OAuth tokens; CalDAV's `password` is not reachable here. */ +export type GoogleTokenKind = Extract export function getAccountKey(accountId: string, kind: GoogleTokenKind): string { - if (!accountId || !accountId.trim()) { - throw new Error('getAccountKey requires a non-empty accountId') - } - const deviceSuffix = process.env.MEMRY_DEVICE - const base = `${kind}-${accountId}` - return deviceSuffix ? `${base}-${deviceSuffix}` : base + return getProviderAccountKey(accountId, kind) } async function setPassword( @@ -20,51 +32,15 @@ async function setPassword( kind: GoogleTokenKind, value: string | null ): Promise { - const account = getAccountKey(accountId, kind) - - try { - if (!value || value.trim().length === 0) { - await deleteSecret(SERVICE, account) - return - } - - await setSecret(SERVICE, account, value.trim()) - } catch (error) { - throw new Error( - `Failed to store Google Calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` - ) - } + await setProviderSecret({ providerId: GOOGLE_PROVIDER_ID, accountId, kind, value }) } async function getPassword(accountId: string, kind: GoogleTokenKind): Promise { - const account = getAccountKey(accountId, kind) - - try { - // Every consumer of these tokens either overwrites them (the OAuth connect - // and the refresh path), deletes them (disconnect), or only asks whether the - // account is still authorized. So an entry we cannot decrypt — the profiles - // stranded by the v2026-08-06 app-identity rename — must read as absent - // rather than throw, otherwise the pre-write read at oauth.ts kills the - // connect flow before the fresh tokens are ever stored and the account can - // never be reconnected from inside the app. - return await getSecret(SERVICE, account, { treatUnreadableAsAbsent: true }) - } catch (error) { - throw new Error( - `Failed to read Google Calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` - ) - } + return await getProviderSecret({ providerId: GOOGLE_PROVIDER_ID, accountId, kind }) } async function deletePassword(accountId: string, kind: GoogleTokenKind): Promise { - const account = getAccountKey(accountId, kind) - - try { - await deleteSecret(SERVICE, account) - } catch (error) { - throw new Error( - `Failed to delete Google Calendar credential (${account}): ${error instanceof Error ? error.message : 'unknown error'}` - ) - } + await deleteProviderSecret({ providerId: GOOGLE_PROVIDER_ID, accountId, kind }) } export async function storeGoogleCalendarTokens(input: { diff --git a/apps/desktop/src/main/calendar/providers/google/sync-service.ts b/apps/desktop/src/main/calendar/providers/google/sync-service.ts index 0a29478ee..7873008e6 100644 --- a/apps/desktop/src/main/calendar/providers/google/sync-service.ts +++ b/apps/desktop/src/main/calendar/providers/google/sync-service.ts @@ -8,7 +8,7 @@ import { } from './oauth' import { resolveTargetGoogleAccountId } from './account-routing' import { createGoogleCalendarClient } from './client' -import { readCalendarGoogleSettings } from './calendar-google-settings' +import { readCalendarProviderSettings } from '../../provider/settings' import { GOOGLE_CAPABILITIES, GOOGLE_PROVIDER_ID } from './capabilities' import { applyProviderDelete, @@ -44,10 +44,11 @@ export const googleSyncContext: ProviderSyncContext = { listAccountIds: (db) => listGoogleAccountIds(db), resolveDefaultAccountId: (db) => resolveDefaultGoogleAccountId(db), hasConnection: (db) => hasGoogleCalendarConnection(db), - isPushEnabled: (db) => readCalendarGoogleSettings(db).pushEventsToGoogle, + isPushEnabled: (db) => readCalendarProviderSettings(db, GOOGLE_PROVIDER_ID).pushEventsToProvider, resolveTargetAccountId: (db, target, existingBinding) => resolveTargetGoogleAccountId(db, target, existingBinding), - readDefaultTargetCalendarId: (db) => readCalendarGoogleSettings(db).defaultTargetCalendarId + readDefaultTargetCalendarId: (db) => + readCalendarProviderSettings(db, GOOGLE_PROVIDER_ID).defaultTargetCalendarId } export async function discoverGoogleCalendarSources( diff --git a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts index d339abf89..38398912f 100644 --- a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts +++ b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts @@ -279,7 +279,7 @@ export interface MainIpcInvokeHandlers { "settings:getAISettings": (...args: []) => Awaited "settings:getBackupSettings": (...args: []) => Awaited<{ autoBackup: boolean; frequencyHours: 1 | 6 | 12 | 24; maxBackups: number; lastBackupAt: string | null; }> "settings:getCalendarGoogleSettings": (...args: []) => Awaited<{ defaultTargetCalendarId: string | null; onboardingCompleted: boolean; promoteConfirmDismissed: boolean; pushEventsToGoogle: boolean; agentReadEventsConsent: boolean | null; }> - "settings:getCalendarProviderSettings": (...args: [any]) => Awaited<{ defaultTargetCalendarId: string | null; onboardingCompleted: boolean; promoteConfirmDismissed: boolean; pushEventsToGoogle: boolean; agentReadEventsConsent: boolean | null; }> + "settings:getCalendarProviderSettings": (...args: [any]) => Awaited<{ defaultTargetCalendarId: string | null; onboardingCompleted: boolean; promoteConfirmDismissed: boolean; pushEventsToProvider: boolean; agentReadEventsConsent: boolean | null; }> "settings:getCalendarSettings": (...args: []) => Awaited<{ dayCellClickBehavior: "calendar" | "journal"; calendarPageClickOverride: "calendar" | "inherit" | "journal"; weekStartDay: "sunday" | "monday"; showNotesOnCalendar: boolean; }> "settings:getEditorSettings": (...args: []) => Awaited<{ width: string; toolbarMode: "floating" | "sticky"; spellCheck: boolean; }> "settings:getFeaturesSettings": (...args: []) => Awaited<{ home: boolean; inbox: boolean; journal: boolean; tasks: boolean; calendar: boolean; graph: boolean; spatialCanvas: boolean; }> @@ -308,7 +308,7 @@ export interface MainIpcInvokeHandlers { "settings:setAISettings": (...args: [Partial]) => Awaited<{ success: boolean; error: string; } | { success: boolean; error?: undefined; }> "settings:setBackupSettings": (...args: [Partial<{ autoBackup: boolean; frequencyHours: 1 | 6 | 12 | 24; maxBackups: number; lastBackupAt: string | null; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setCalendarGoogleSettings": (...args: [Partial<{ defaultTargetCalendarId: string | null; onboardingCompleted: boolean; promoteConfirmDismissed: boolean; pushEventsToGoogle: boolean; agentReadEventsConsent: boolean | null; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> - "settings:setCalendarProviderSettings": (...args: [string, Partial<{ defaultTargetCalendarId: string | null; onboardingCompleted: boolean; promoteConfirmDismissed: boolean; pushEventsToGoogle: boolean; agentReadEventsConsent: boolean | null; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> + "settings:setCalendarProviderSettings": (...args: [string, Partial<{ defaultTargetCalendarId: string | null; onboardingCompleted: boolean; promoteConfirmDismissed: boolean; pushEventsToProvider: boolean; agentReadEventsConsent: boolean | null; }>]) => Awaited<{ success: boolean; error: string; } | { success: boolean; error?: undefined; }> "settings:setCalendarSettings": (...args: [Partial<{ dayCellClickBehavior: "calendar" | "journal"; calendarPageClickOverride: "calendar" | "inherit" | "journal"; weekStartDay: "sunday" | "monday"; showNotesOnCalendar: boolean; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setEditorSettings": (...args: [Partial<{ width: "normal" | "full"; toolbarMode: "floating" | "sticky"; spellCheck: boolean; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setFeaturesSettings": (...args: [Partial<{ home: boolean; inbox: boolean; journal: boolean; tasks: boolean; calendar: boolean; graph: boolean; spatialCanvas: boolean; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> diff --git a/apps/desktop/src/main/ipc/settings-handlers.test.ts b/apps/desktop/src/main/ipc/settings-handlers.test.ts index f85970cfc..6b4aafa3a 100644 --- a/apps/desktop/src/main/ipc/settings-handlers.test.ts +++ b/apps/desktop/src/main/ipc/settings-handlers.test.ts @@ -1151,4 +1151,116 @@ describe('settings-handlers', () => { expect(removeHandlerCalls).toContain(SettingsChannels.invoke.SET_CALENDAR_GOOGLE_SETTINGS) }) }) + describe('calendar. settings group (#1394)', () => { + it('#given no stored row #when GET invoked for a new provider #then returns neutral defaults', async () => { + registerSettingsHandlers() + ;(settingsQueries.getSetting as Mock).mockReturnValue(null) + + const result = await invokeHandler<{ + defaultTargetCalendarId: string | null + pushEventsToProvider: boolean + agentReadEventsConsent: boolean | null + }>(SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, 'caldav') + + expect(result.defaultTargetCalendarId).toBeNull() + expect(result.pushEventsToProvider).toBe(true) + // Never asked reads as "the agent may not read this provider". + expect(result.agentReadEventsConsent).toBeNull() + }) + + it('#given a legacy calendar.google row #when GET invoked for google #then translates the historic key', async () => { + registerSettingsHandlers() + ;(settingsQueries.getSetting as Mock).mockReturnValue( + JSON.stringify({ + defaultTargetCalendarId: 'primary@group.calendar.google.com', + onboardingCompleted: true, + promoteConfirmDismissed: true, + pushEventsToGoogle: false, + agentReadEventsConsent: true + }) + ) + + const result = await invokeHandler<{ + defaultTargetCalendarId: string | null + pushEventsToProvider: boolean + }>(SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, 'google') + + expect(settingsQueries.getSetting).toHaveBeenCalledWith(expect.anything(), 'calendar.google') + expect(result.defaultTargetCalendarId).toBe('primary@group.calendar.google.com') + expect(result.pushEventsToProvider).toBe(false) + }) + + it('#when SET invoked for a new provider #then writes calendar. and broadcasts', async () => { + registerSettingsHandlers() + ;(settingsQueries.getSetting as Mock).mockReturnValue(null) + + const result = await invokeHandler<{ success: boolean }>( + SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS, + 'caldav', + { agentReadEventsConsent: true } + ) + + expect(result.success).toBe(true) + const [, key, value] = (settingsQueries.setSetting as Mock).mock.calls.at(-1) as [ + unknown, + string, + string + ] + expect(key).toBe('calendar.caldav') + expect(JSON.parse(value)).toMatchObject({ agentReadEventsConsent: true }) + expect(mockSend).toHaveBeenCalledWith( + SettingsChannels.events.CHANGED, + expect.objectContaining({ key: 'calendar.caldav' }) + ) + }) + + it('#when SET invoked for google #then keeps the historic pushEventsToGoogle key', async () => { + registerSettingsHandlers() + ;(settingsQueries.getSetting as Mock).mockReturnValue(null) + + await invokeHandler(SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS, 'google', { + pushEventsToProvider: false + }) + + const [, key, value] = (settingsQueries.setSetting as Mock).mock.calls.at(-1) as [ + unknown, + string, + string + ] + expect(key).toBe('calendar.google') + const written = JSON.parse(value) as Record + expect(written.pushEventsToGoogle).toBe(false) + expect(written).not.toHaveProperty('pushEventsToProvider') + }) + + it('#given no vault open #then GET falls back to defaults and SET reports the error', async () => { + registerSettingsHandlers() + ;(getDatabase as Mock).mockImplementation(() => { + throw new Error('Database not initialized') + }) + + const read = await invokeHandler<{ pushEventsToProvider: boolean }>( + SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, + 'caldav' + ) + const write = await invokeHandler<{ success: boolean; error?: string }>( + SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS, + 'caldav', + { agentReadEventsConsent: true } + ) + + expect(read.pushEventsToProvider).toBe(true) + expect(write.success).toBe(false) + expect(write.error).toBeTruthy() + expect(settingsQueries.setSetting).not.toHaveBeenCalled() + }) + + it('unregisters both provider channels alongside the google aliases', () => { + registerSettingsHandlers() + unregisterSettingsHandlers() + + expect(removeHandlerCalls).toContain(SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS) + expect(removeHandlerCalls).toContain(SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS) + }) + }) }) diff --git a/apps/desktop/src/main/ipc/settings-handlers.ts b/apps/desktop/src/main/ipc/settings-handlers.ts index e01658ec4..c0c778703 100644 --- a/apps/desktop/src/main/ipc/settings-handlers.ts +++ b/apps/desktop/src/main/ipc/settings-handlers.ts @@ -19,6 +19,7 @@ import { BACKUP_SETTINGS_DEFAULTS, VOICE_TRANSCRIPTION_SETTINGS_DEFAULTS, CALENDAR_GOOGLE_SETTINGS_DEFAULTS, + CALENDAR_PROVIDER_SETTINGS_DEFAULTS, CALENDAR_SETTINGS_DEFAULTS, FEATURES_SETTINGS_DEFAULTS, INBOX_SETTINGS_DEFAULTS @@ -32,12 +33,17 @@ import type { BackupSettings, VoiceTranscriptionSettings, CalendarGoogleSettings, + CalendarProviderSettings, CalendarSettings, FeaturesSettings, InboxSettings } from '@memry/contracts/settings-schemas' import { GRAPH_SETTINGS_DEFAULTS } from '@memry/contracts/graph-api' import type { GraphSettings } from '@memry/contracts/graph-api' +import { + readCalendarProviderSettings, + writeCalendarProviderSettings +} from '../calendar/provider/settings' import { createLogger } from '../lib/logger' import { getDatabase } from '../database' import { getSetting, setSetting, deleteSetting } from '../settings/settings-store' @@ -928,25 +934,27 @@ export function registerSettingsHandlers(): void { writeGroupSettings('graph', GRAPH_SETTINGS_DEFAULTS, updates) ) - // `calendar.` — google keeps its exact historical key, so an - // existing install reads and writes the same row it always has. The - // per-provider schema split lands in #1394. - const calendarProviderSettingsKey = (providerId: string): string => `calendar.${providerId}` - - ipcMain.handle(SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, (_event, providerId) => - readGroupSettings( - calendarProviderSettingsKey(String(providerId)), - CALENDAR_GOOGLE_SETTINGS_DEFAULTS - ) - ) + // `calendar.`, in the neutral shape. Google's row keeps its exact + // historical key and keys — `readCalendarProviderSettings` translates its + // `pushEventsToGoogle` to `pushEventsToProvider` on the way out, so an + // existing install reads and writes the same row it always has (#1394). + ipcMain.handle(SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, (_event, providerId) => { + const db = getDbOrNull() + if (!db) return { ...CALENDAR_PROVIDER_SETTINGS_DEFAULTS } + return readCalendarProviderSettings(db, String(providerId)) + }) ipcMain.handle( SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS, - (_event, providerId: string, updates: Partial) => - writeGroupSettings( - calendarProviderSettingsKey(String(providerId)), - CALENDAR_GOOGLE_SETTINGS_DEFAULTS, - updates - ) + (_event, providerId: string, updates: Partial) => { + const db = getDbOrNull() + if (!db) return { success: false, error: getMainI18n().t('errors:ipc.noVaultOpen') } + writeCalendarProviderSettings(db, String(providerId), updates) + broadcastToAllWindows(SettingsChannels.events.CHANGED, { + key: `calendar.${String(providerId)}`, + value: updates + }) + return { success: true } + } ) // Permanent compatibility aliases: an older renderer against a newer main diff --git a/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.test.tsx b/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.test.tsx index 3f1f3b445..c15098c1d 100644 --- a/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.test.tsx +++ b/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.test.tsx @@ -23,10 +23,11 @@ describe('useAgentMcpDesktopApiResponder', () => { let respondToMainInvoke: ReturnType let templatesList: ReturnType let templatesCreate: ReturnType + let calendarListProviders: ReturnType let calendarGetProviderStatus: ReturnType let calendarGetRange: ReturnType let calendarListEvents: ReturnType - let getCalendarGoogleSettings: ReturnType + let getCalendarProviderSettings: ReturnType beforeEach(() => { onMainInvokeCallback = undefined @@ -40,8 +41,11 @@ describe('useAgentMcpDesktopApiResponder', () => { calendarGetRange = vi.fn().mockResolvedValue({ items: [] }) calendarListEvents = vi.fn().mockResolvedValue({ events: [] }) // null = the user has not answered the agent-access prompt yet. Until they - // grant it, Google-synced events stay out of every agent read. - getCalendarGoogleSettings = vi.fn().mockResolvedValue({ agentReadEventsConsent: null }) + // grant it, that provider's synced events stay out of every agent read. + getCalendarProviderSettings = vi.fn().mockResolvedValue({ agentReadEventsConsent: null }) + calendarListProviders = vi.fn().mockResolvedValue({ + providers: [{ id: 'google', capabilities: null }] + }) mocks.logError.mockReset() ;(window as Window & { api: unknown }).api = { onMainInvoke: vi.fn( @@ -62,12 +66,13 @@ describe('useAgentMcpDesktopApiResponder', () => { create: templatesCreate }, calendar: { + listProviders: calendarListProviders, getProviderStatus: calendarGetProviderStatus, getRange: calendarGetRange, listEvents: calendarListEvents }, settings: { - getCalendarGoogleSettings + getCalendarProviderSettings } } }) @@ -123,8 +128,10 @@ describe('useAgentMcpDesktopApiResponder', () => { 'calendar.getProviderStatus', 'calendar.listSources', 'calendar.listGoogleCalendars', + 'calendar.listProviderCalendars', 'calendar.promoteExternalEvent', - 'settings.getCalendarGoogleSettings' + 'settings.getCalendarGoogleSettings', + 'settings.getCalendarProviderSettings' ] for (const operation of operations) { @@ -212,7 +219,7 @@ describe('useAgentMcpDesktopApiResponder', () => { }) it('keeps Google events out of range reads when consent is denied, ignoring caller flags', async () => { - getCalendarGoogleSettings.mockResolvedValue({ agentReadEventsConsent: false }) + getCalendarProviderSettings.mockResolvedValue({ agentReadEventsConsent: false }) renderHook(() => useAgentMcpDesktopApiResponder()) await waitFor(() => expect(window.api.onMainInvoke).toHaveBeenCalled()) @@ -226,7 +233,7 @@ describe('useAgentMcpDesktopApiResponder', () => { }) it('includes Google events in range reads once the user grants consent', async () => { - getCalendarGoogleSettings.mockResolvedValue({ agentReadEventsConsent: true }) + getCalendarProviderSettings.mockResolvedValue({ agentReadEventsConsent: true }) renderHook(() => useAgentMcpDesktopApiResponder()) await waitFor(() => expect(window.api.onMainInvoke).toHaveBeenCalled()) @@ -240,7 +247,7 @@ describe('useAgentMcpDesktopApiResponder', () => { }) it('falls back to excluding Google events when the consent lookup fails', async () => { - getCalendarGoogleSettings.mockRejectedValue(new Error('settings unavailable')) + getCalendarProviderSettings.mockRejectedValue(new Error('settings unavailable')) renderHook(() => useAgentMcpDesktopApiResponder()) await waitFor(() => expect(window.api.onMainInvoke).toHaveBeenCalled()) diff --git a/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.ts b/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.ts index 6b6f7694b..98a2d7f75 100644 --- a/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.ts +++ b/apps/desktop/src/renderer/src/agent-mcp/desktop-api-handler.ts @@ -41,13 +41,27 @@ async function normalizeDesktopApiArgs(operation: string, args: unknown[]): Prom } } -// Google Workspace Limited Use: Google-synced events are invisible to the agent -// until the user explicitly opts in. Anything other than a stored `true` — not -// asked yet, opted out, or a settings read that failed — stays native-only. -async function hasAgentGoogleEventConsent(): Promise { +/** + * External calendar events are invisible to the agent until every calendar + * provider this build can connect has been explicitly consented to. Anything + * other than a stored `true` — never asked, opted out, or a settings read that + * failed — stays native-only. + * + * Google Workspace Limited Use forced the question first; the answer is the + * house rule now (#1394). The gate is all-or-nothing because + * `calendar.getRange` takes a single `includeExternal` flag: until it can + * filter per provider, one unconsented provider keeps every external event out + * rather than risking one leaking through. + */ +async function hasAgentExternalEventConsent(): Promise { try { - const settings = await window.api.settings.getCalendarGoogleSettings() - return settings.agentReadEventsConsent === true + const { providers } = await window.api.calendar.listProviders() + if (providers.length === 0) return false + + const settings = await Promise.all( + providers.map((provider) => window.api.settings.getCalendarProviderSettings(provider.id)) + ) + return settings.every((group) => group.agentReadEventsConsent === true) } catch (error) { log.warn('Calendar consent lookup failed; keeping agent reads native-only', error) return false @@ -70,7 +84,7 @@ async function normalizeCalendarRangeInput(args: unknown[]): Promise endAt: end ? normalizeCalendarRangeBound(end, 'end') : end, // Resolved from stored consent, never from the caller: an agent that asks // for external events cannot talk its way past the user's answer. - includeExternal: await hasAgentGoogleEventConsent() + includeExternal: await hasAgentExternalEventConsent() } } diff --git a/apps/docs/src/user-guide/ai/agent-mcp.md b/apps/docs/src/user-guide/ai/agent-mcp.md index ec0d5a065..750a9296d 100644 --- a/apps/docs/src/user-guide/ai/agent-mcp.md +++ b/apps/docs/src/user-guide/ai/agent-mcp.md @@ -457,14 +457,17 @@ Calendar desktop reads accept the same single-object shape as the renderer bridg `args: ["2026-05-14", "2026-06-14"]` or `args: [{"startAt": "2026-05-14T00:00:00.000Z", "endAt": "2026-06-15T00:00:00.000Z"}]`. -Google-integration operations — calendar sources, provider status, Google calendar lists, promoting -external events, and Google calendar settings — are excluded from the agent allowlists outright. - -Google-synced events themselves are gated on explicit user consent. `calendar.getRange` resolves -`includeExternal` from the stored answer to the **Let AI read Google Calendar events** setting, never -from the caller: an agent that passes `includeExternal: true` still gets native-only results unless -the user granted access. Not asked yet, declined, or a settings read that failed all resolve to -native-only. See [Calendar → Google Data and AI Features](/user-guide/calendar#google-data-and-ai-features). +Calendar-integration operations — calendar sources, provider status, provider calendar lists, +promoting external events, and calendar provider settings — are excluded from the agent allowlists +outright. That applies to the provider-neutral operations and their Google-named aliases alike; they +reach the same connected accounts, so allowlisting either would re-open the same hole. + +Events synced from a connected calendar are gated on explicit per-provider consent. +`calendar.getRange` resolves `includeExternal` from the stored answers, never from the caller: an +agent that passes `includeExternal: true` still gets native-only results unless every connected +provider has been granted access. Not asked yet, declined, or a settings read that failed all +resolve to native-only. See +[Calendar → Connected Calendars and AI Features](/user-guide/calendar#connected-calendars-and-ai-features). Google user data is never used to train or improve AI models, in line with the Google API Services User Data Policy (Limited Use). diff --git a/apps/docs/src/user-guide/calendar.md b/apps/docs/src/user-guide/calendar.md index 3d3cb922d..1ee676bc4 100644 --- a/apps/docs/src/user-guide/calendar.md +++ b/apps/docs/src/user-guide/calendar.md @@ -199,7 +199,29 @@ an account whose sign-in it cannot read. See Right-click an external event → **Promote to vault** to copy it into your encrypted vault. Useful when you want to attach notes, tags, or reminders that wouldn't survive on the source calendar. -### Google Data and AI Features +### Connected Calendars and AI Features + +**The rule: no connected calendar is readable by the AI assistant until you say so, one provider at +a time.** Google Workspace's Limited Use terms are what made us ask the question first, but the +answer is the house rule for every calendar you connect — Google today, and ICS feeds, CalDAV +servers and Outlook as they arrive. Each provider is asked separately, and consenting to one says +nothing about another. + +Three details follow from that: + +- **Not asked yet reads as no.** Until you answer, the assistant sees nothing from that calendar. + There is no "on by default" state. +- **Turning a provider off does not touch the others.** Your answers are stored per provider, in + that provider's own settings group. +- **While any connected provider is unconsented, external events stay out entirely.** The + assistant's calendar reads carry one include-external flag rather than a per-provider list, so + memrynote errs on the side of showing it nothing. Consent to every connected provider — or + disconnect the one you don't want it reading — to open the gate. + +Events you [promoted to your vault](#promote-external-events) are exempt: a promoted event is your +own memrynote event, and the assistant reads it like any other. + +#### Google specifically AI access to your Google Calendar events is off until you turn it on. The first time you open the calendar with Google calendars imported, memrynote asks once: **Let AI read your Google Calendar diff --git a/packages/contracts/src/settings-schemas.ts b/packages/contracts/src/settings-schemas.ts index e15382de8..6a831fc86 100644 --- a/packages/contracts/src/settings-schemas.ts +++ b/packages/contracts/src/settings-schemas.ts @@ -218,6 +218,44 @@ export const CALENDAR_GOOGLE_SETTINGS_DEFAULTS: CalendarGoogleSettings = { agentReadEventsConsent: null } +// ============================================================================ +// Calendar — per-provider settings (`calendar.`) +// ============================================================================ +// +// The shape above is the *google* instance of this base, frozen in its historic +// form: its outbound toggle is spelled `pushEventsToGoogle` and its group key is +// `calendar.google`. Both stay exactly as they are — a live install must read +// back the row it already wrote, and there is no migration. +// +// Every provider added from here on stores `calendar.` in this +// neutral shape, where the same toggle is `pushEventsToProvider`. The main +// process translates between the two so the sync engine only ever sees the +// neutral name. +// +// `agentReadEventsConsent` is deliberately part of the *base*, not a Google +// extension. Google Workspace Limited Use is what forced the question first, +// but the answer became the house rule: no provider's external events are +// readable by the agent until that provider has been explicitly consented to. +// Null means "not asked yet" and reads as no. +export const CalendarProviderSettingsSchema = z.object({ + defaultTargetCalendarId: z.string().nullable(), + onboardingCompleted: z.boolean(), + promoteConfirmDismissed: z.boolean(), + /** false = one-way (inbound only): pull events in, never push ours out. */ + pushEventsToProvider: z.boolean(), + agentReadEventsConsent: z.boolean().nullable().default(null) +}) + +export type CalendarProviderSettings = z.infer + +export const CALENDAR_PROVIDER_SETTINGS_DEFAULTS: CalendarProviderSettings = { + defaultTargetCalendarId: null, + onboardingCompleted: false, + promoteConfirmDismissed: false, + pushEventsToProvider: true, + agentReadEventsConsent: null +} + // ============================================================================ // Features Settings (optional module toggles) // ============================================================================ diff --git a/packages/rpc/src/settings.ts b/packages/rpc/src/settings.ts index 49d826e76..5c2cac8cf 100644 --- a/packages/rpc/src/settings.ts +++ b/packages/rpc/src/settings.ts @@ -1,6 +1,7 @@ import type { BackupSettings, CalendarGoogleSettings, + CalendarProviderSettings, CalendarSettings, EditorSettings, FeaturesSettings, @@ -283,13 +284,13 @@ export const settingsRpc = defineDomain({ params: ['settings'] }), getCalendarProviderSettings: defineMethod< - (providerId: string) => Promise + (providerId: string) => Promise >({ channel: SettingsChannels.invoke.GET_CALENDAR_PROVIDER_SETTINGS, params: ['providerId'] }), setCalendarProviderSettings: defineMethod< - (providerId: string, settings: Partial) => SuccessResponse + (providerId: string, settings: Partial) => SuccessResponse >({ channel: SettingsChannels.invoke.SET_CALENDAR_PROVIDER_SETTINGS, params: ['providerId', 'settings']