From b215b26b2b8da2f50f98818320747b8e8f5953a2 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 9 Apr 2026 01:21:02 +0530 Subject: [PATCH 1/3] Enhance Gmail and Notion integration with new features and improvements - Add `serve:core` script to package.json for easier core serving. - Update default RUNTIME_URL in test harness to 7788 for consistency. - Refactor Gmail skill by removing unused token cache logic and improving error handling. - Implement interactive credential prompts in Gmail live test sync for better user experience. - Streamline Gmail API requests to always use OAuth proxy for improved security and consistency. - Update Notion helper functions to ensure all requests utilize the OAuth proxy, enhancing compatibility. These changes improve the overall functionality and user experience of the Gmail and Notion integrations, ensuring better performance and security. --- dev/test-harness/index.ts | 2 +- openhuman | 2 +- package.json | 1 + scripts/serve-core.mjs | 98 +++++++++++++ src/core/gmail/api/index.ts | 161 ++------------------- src/core/gmail/index.ts | 6 +- src/core/gmail/live-test-sync.ts | 231 ++++++++++++++++++++++++++++--- src/core/gmail/sync.ts | 4 + src/core/notion/helpers.ts | 19 ++- 9 files changed, 336 insertions(+), 188 deletions(-) mode change 160000 => 120000 openhuman create mode 100644 scripts/serve-core.mjs diff --git a/dev/test-harness/index.ts b/dev/test-harness/index.ts index 5ae559f..097999f 100644 --- a/dev/test-harness/index.ts +++ b/dev/test-harness/index.ts @@ -25,7 +25,7 @@ // RPC Client // --------------------------------------------------------------------------- -const RUNTIME_URL = process.env.SKILLS_RUNTIME_URL || 'http://127.0.0.1:7799'; +const RUNTIME_URL = process.env.SKILLS_RUNTIME_URL || 'http://127.0.0.1:7788'; let rpcId = 0; diff --git a/openhuman b/openhuman deleted file mode 160000 index 3034ec1..0000000 --- a/openhuman +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3034ec1fa2bc2593f3ddf9f851051d55473abb7f diff --git a/openhuman b/openhuman new file mode 120000 index 0000000..da85bad --- /dev/null +++ b/openhuman @@ -0,0 +1 @@ +/Users/megamind/tinyhuman/openhuman \ No newline at end of file diff --git a/package.json b/package.json index 6cc5ea0..6ad948e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "prepare": "husky", "core:build": "cargo build --manifest-path openhuman/Cargo.toml --bin openhuman-core", "dev:runtime": "(cd openhuman && cargo build) && node scripts/dev-runtime.mjs", + "serve:core": "node scripts/serve-core.mjs", "core:run": "cargo run --manifest-path openhuman/Cargo.toml --bin openhuman-core -- skills run --skills-dir ./skills", "core:list": "cargo run --manifest-path openhuman/Cargo.toml --bin openhuman-core -- skills list --skills-dir ./skills", "core:test": "cargo run --manifest-path openhuman/Cargo.toml --bin openhuman-core -- skills test --skills-dir ./skills", diff --git a/scripts/serve-core.mjs b/scripts/serve-core.mjs new file mode 100644 index 0000000..cc4ffb3 --- /dev/null +++ b/scripts/serve-core.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Start openhuman `serve` with openhuman-skills/.env applied (BACKEND_URL, JWT_TOKEN, …). + * + * Usage: + * npm run serve:core + * npm run serve:core -- --port 7788 + * + * Finds the openhuman repo via: ./openhuman (submodule), ../openhuman (sibling), or OPENHUMAN_ROOT. + */ + +import { spawn } from 'child_process'; +import { existsSync, readFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = resolve(__dirname, '..'); + +function loadEnvFromSkillsDotenv() { + const envPath = resolve(rootDir, '.env'); + const envVars = { ...process.env }; + if (!existsSync(envPath)) { + console.warn(`\x1b[33m No .env at ${envPath} — BACKEND_URL may default in skills\x1b[0m`); + return envVars; + } + for (const line of readFileSync(envPath, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const value = trimmed.slice(eqIdx + 1).trim(); + if (!process.env[key] && value) { + envVars[key] = value; + } + } + console.log(`\x1b[2m Loaded .env from ${envPath}\x1b[0m`); + return envVars; +} + +function findOpenhumanRoot() { + const fromSubmodule = resolve(rootDir, 'openhuman', 'Cargo.toml'); + if (existsSync(fromSubmodule)) { + return resolve(rootDir, 'openhuman'); + } + const sibling = resolve(rootDir, '..', 'openhuman', 'Cargo.toml'); + if (existsSync(sibling)) { + return resolve(rootDir, '..', 'openhuman'); + } + if (process.env.OPENHUMAN_ROOT) { + const o = resolve(process.env.OPENHUMAN_ROOT); + if (existsSync(resolve(o, 'Cargo.toml'))) { + return o; + } + } + console.error( + '\x1b[31m Could not find openhuman (expected ./openhuman or ../openhuman). Set OPENHUMAN_ROOT.\x1b[0m' + ); + process.exit(1); +} + +const argv = process.argv.slice(2); +let port = 7788; +const portIdx = argv.indexOf('--port'); +if (portIdx !== -1 && argv[portIdx + 1]) { + port = parseInt(argv[portIdx + 1], 10); +} + +const env = loadEnvFromSkillsDotenv(); +const openhumanRoot = findOpenhumanRoot(); +const backend = env.BACKEND_URL || '(unset — skills default)'; +const jwt = env.JWT_TOKEN ? `<${String(env.JWT_TOKEN).length} chars>` : '(unset)'; + +console.log(`\n\x1b[36m openhuman serve (with skills .env)\x1b[0m`); +console.log(`\x1b[2m Port: ${port}\x1b[0m`); +console.log(`\x1b[2m Backend: ${backend}\x1b[0m`); +console.log(`\x1b[2m JWT: ${jwt}\x1b[0m`); +console.log(`\x1b[2m Core: ${openhumanRoot}\x1b[0m\n`); + +const child = spawn( + 'cargo', + ['run', '--bin', 'openhuman-core', '--', 'serve', '--port', String(port)], + { + cwd: openhumanRoot, + stdio: 'inherit', + env: { + ...env, + RUST_LOG: env.RUST_LOG || 'info', + }, + } +); + +child.on('exit', (code) => { + process.exit(code ?? 1); +}); +process.on('SIGINT', () => child.kill('SIGINT')); +process.on('SIGTERM', () => child.kill('SIGTERM')); diff --git a/src/core/gmail/api/index.ts b/src/core/gmail/api/index.ts index c378353..2892533 100644 --- a/src/core/gmail/api/index.ts +++ b/src/core/gmail/api/index.ts @@ -17,101 +17,6 @@ function sleep(ms: number): void { } } -const GMAIL_BASE_URL = 'https://gmail.googleapis.com/gmail/v1'; -const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; - -/** Cached access token for self_hosted mode (refresh_token → access_token exchange). */ -let cachedSelfHostedToken: { token: string; expiresAt: number } | null = null; - -/** - * Resolve a Gmail access token from the available credential sources. - * - * Priority: - * 1. Advanced auth (self_hosted): exchange refresh_token for access_token - * 2. Advanced auth (text): use service account key (access_token field if pre-exchanged) - * 3. OAuth credential with accessToken - * 4. null — not connected - */ -function resolveAccessToken(): string | null { - // Check advanced auth bridge first - const authCred = auth.getCredential(); - if (authCred && authCred.mode === 'self_hosted') { - const creds = authCred.credentials; - const clientId = creds.client_id as string | undefined; - const clientSecret = creds.client_secret as string | undefined; - const refreshToken = creds.refresh_token as string | undefined; - - if (clientId && clientSecret && refreshToken) { - // Use cached token if still valid (with 60s buffer) - if (cachedSelfHostedToken && cachedSelfHostedToken.expiresAt > Date.now() + 60_000) { - return cachedSelfHostedToken.token; - } - - // Exchange refresh_token for access_token - try { - const body = `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&refresh_token=${encodeURIComponent(refreshToken)}&grant_type=refresh_token`; - const response = net.fetch(GOOGLE_TOKEN_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - timeout: 15, - }); - - if (response.status === 200) { - const data = JSON.parse(response.body) as { access_token: string; expires_in: number }; - cachedSelfHostedToken = { - token: data.access_token, - expiresAt: Date.now() + data.expires_in * 1000, - }; - console.log('[gmail] Self-hosted token refreshed, expires in', data.expires_in, 's'); - return data.access_token; - } else { - console.error('[gmail] Token refresh failed:', response.status, response.body); - return null; - } - } catch (err) { - console.error('[gmail] Token refresh error:', err); - return null; - } - } - } - - if (authCred && authCred.mode === 'text') { - // Text mode: the user pasted a service account JSON or an access token. - // If the content looks like a raw token (no JSON structure), use it directly. - const content = (authCred.credentials.content || '') as string; - try { - const parsed = JSON.parse(content) as Record; - // Service account JSON — would need JWT exchange (complex). - // For now, check if there's an access_token or private_key field. - if (parsed.access_token) { - return parsed.access_token as string; - } - // Service account with private_key: not supported yet in QuickJS runtime - // (would need JWT signing). Log a helpful message. - if (parsed.private_key) { - console.warn( - '[gmail] Service account JSON detected but JWT signing is not yet supported. Use a refresh token flow instead.' - ); - return null; - } - } catch { - // Not JSON — treat as a raw access/bearer token - if (content.trim()) { - return content.trim(); - } - } - return null; - } - - // Fall back to OAuth credential - const oauthCred = oauth.getCredential(); - if (oauthCred && oauthCred.accessToken) { - return oauthCred.accessToken as string; - } - - return null; -} /** Returns true if any form of Gmail credential is available. */ export function isGmailConnected(): boolean { @@ -121,11 +26,6 @@ export function isGmailConnected(): boolean { return !!oauthCred; } -/** Reset cached self-hosted token (e.g., on credential change). */ -export function resetTokenCache(): void { - cachedSelfHostedToken = null; -} - export interface GmailApiResponse { success: boolean; data?: T; @@ -145,19 +45,11 @@ export function gmailFetch( const cleanPath = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; const method = options.method || 'GET'; const timeout = options.timeout || 10; - const isBatch = cleanPath === '/batch'; - - // Determine whether to use direct API calls (with a resolved access token) - // or the OAuth proxy (for encrypted/managed credentials without a local token). const oauthCred = oauth.getCredential(); for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - // Resolve token inside the loop so retries after 401 cache clear get a fresh token - const accessToken = resolveAccessToken(); - const useProxy = !accessToken && !!oauthCred; - - if (!accessToken && !useProxy) { - console.log('[gmail] gmailFetch: no access token and no OAuth credential'); + if (!oauthCred) { + console.log('[gmail] gmailFetch: no OAuth credential'); return { success: false, error: { code: 401, message: 'Gmail not connected. Complete setup first.' }, @@ -165,36 +57,15 @@ export function gmailFetch( } try { - let response: { status: number; headers: Record; body: string }; - - if (useProxy) { - // Managed/encrypted OAuth: use the proxy which decrypts tokens server-side. - // oauth.fetch path is relative to the manifest apiBaseUrl (gmail.googleapis.com/gmail/v1), - // so pass the endpoint path directly without the /gmail/v1 prefix. - console.log('[gmail] gmailFetch (proxy):', method, cleanPath); - response = oauth.fetch(cleanPath, { - method, - headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, - body: options.body, - timeout, - }); - } else { - // Direct API call with resolved access token - const url = isBatch - ? 'https://www.googleapis.com/batch/gmail/v1' - : `${GMAIL_BASE_URL}${cleanPath}`; - console.log('[gmail] gmailFetch (direct):', method, url); - response = net.fetch(url, { - method, - headers: { - Authorization: `Bearer ${accessToken}`, - ...(isBatch ? {} : { 'Content-Type': 'application/json' }), - ...(options.headers || {}), - }, - body: options.body, - timeout, - }); - } + // All Gmail API requests go through OAuth proxy. + // oauth.fetch path is relative to the manifest apiBaseUrl. + console.log('[gmail] gmailFetch (oauth.fetch):', method, cleanPath); + const response = oauth.fetch(cleanPath, { + method, + headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, + body: options.body, + timeout, + }); console.log('[gmail] gmailFetch response status:', response.status); const s = getGmailSkillState(); @@ -212,15 +83,7 @@ export function gmailFetch( continue; } - // -- 401 Unauthorized: invalidate cached token and retry with fresh one - - if (!useProxy && response.status === 401 && attempt < MAX_RETRIES) { - const bodyPreview = response.body ? response.body.slice(0, 200) : '(empty)'; - console.log(`[gmail] gmailFetch: 401 Unauthorized body=${bodyPreview}`); - cachedSelfHostedToken = null; - // Token will be re-resolved at the top of the next iteration - console.log('[gmail] gmailFetch: cleared token cache, retrying'); - continue; - } else if (response.status >= 400) { + if (response.status >= 400) { const bodyPreview = response.body ? response.body.slice(0, 200) : '(empty)'; console.log(`[gmail] gmailFetch: error status=${response.status} body=${bodyPreview}`); } diff --git a/src/core/gmail/index.ts b/src/core/gmail/index.ts index c677e7a..10c92a8 100644 --- a/src/core/gmail/index.ts +++ b/src/core/gmail/index.ts @@ -1,7 +1,7 @@ // Gmail skill main entry point // Gmail integration with OAuth bridge; sync sends list API response (id + threadId) to frontend. import { loadGmailProfile } from './api/helpers'; -import { isGmailConnected, resetTokenCache } from './api/index'; +import { isGmailConnected } from './api/index'; import { getEmailCount } from './db/helpers'; import { initializeGmailSchema } from './db/schema'; import { getGmailSkillState } from './state'; @@ -161,9 +161,6 @@ function onAuthComplete(args: { mode: string; credentials: Record(fn: () => Promise): Promise<[T, number]> { } // --------------------------------------------------------------------------- -// Config +// Prompt (interactive credential resolution) // --------------------------------------------------------------------------- -const INTEGRATION_ID = (process.env.GMAIL_INTEGRATION_ID || '').trim(); -const CLIENT_KEY = (process.env.GMAIL_CLIENT_KEY_SHARE || '').trim(); +const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + +function prompt(question: string, defaultValue?: string): Promise { + const suffix = defaultValue ? ` ${C.dim}[${defaultValue}]${C.reset}` : ''; + return new Promise(resolve => { + rl.question(`${C.yellow} ? ${question}${suffix}: ${C.reset}`, answer => { + resolve(answer.trim() || defaultValue || ''); + }); + }); +} + +function promptSecret(question: string): Promise { + return new Promise(resolve => { + process.stdout.write(`${C.yellow} ? ${question}: ${C.reset}`); + const stdin = process.stdin; + const wasRaw = stdin.isRaw; + if (stdin.isTTY) stdin.setRawMode(true); + let input = ''; + const onData = (ch: Buffer) => { + const c = ch.toString(); + if (c === '\n' || c === '\r') { + if (stdin.isTTY) stdin.setRawMode(wasRaw ?? false); + stdin.removeListener('data', onData); + console.log(); + resolve(input.trim()); + } else if (c === '\x7f' || c === '\b') { + input = input.slice(0, -1); + } else if (c === '\x03') { + process.exit(1); + } else { + input += c; + } + }; + stdin.on('data', onData); + }); +} + +function openUrl(url: string) { + const cmd = + process.platform === 'darwin' + ? `open "${url}"` + : process.platform === 'win32' + ? `start "${url}"` + : `xdg-open "${url}"`; + exec(cmd, err => { + if (err) console.warn(`${C.dim} (could not open browser: ${err.message})${C.reset}`); + }); +} + +const GRANTED_SCOPES = [ + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/gmail.modify', + 'https://www.googleapis.com/auth/gmail.labels', +]; + +type ResolvedCreds = + | { mode: 'encrypted_oauth'; integrationId: string; clientKeyShare: string } + | { + mode: 'self_hosted'; + clientId: string; + clientSecret: string; + refreshToken: string; + }; + +/** Resolve JWT (required), then OAuth env or interactive — mirrors live-test.ts */ +async function resolveCredentials(): Promise { + const jwt = (process.env.JWT_TOKEN || '').trim(); + if (!jwt) { + console.error(`\n${C.red} JWT_TOKEN env var is required.${C.reset}`); + console.error( + `${C.dim} Usage: JWT_TOKEN= npx tsx src/core/gmail/live-test-sync.ts${C.reset}\n` + ); + process.exit(1); + } + + const BACKEND_URL = (process.env.BACKEND_URL || 'https://api.tinyhumans.ai').replace(/\/+$/, ''); + const ENV_AUTH_MODE = process.env.AUTH_MODE || ''; + const ENV_INTEGRATION_ID = (process.env.GMAIL_INTEGRATION_ID || '').trim(); + const ENV_CLIENT_KEY = (process.env.GMAIL_CLIENT_KEY_SHARE || '').trim(); + const ENV_CLIENT_ID = (process.env.GMAIL_CLIENT_ID || '').trim(); + const ENV_CLIENT_SECRET = (process.env.GMAIL_CLIENT_SECRET || '').trim(); + const ENV_REFRESH_TOKEN = (process.env.GMAIL_REFRESH_TOKEN || '').trim(); + + const hasOAuthEnv = !!(ENV_INTEGRATION_ID && ENV_CLIENT_KEY); + const hasSelfHostedEnv = !!(ENV_CLIENT_ID && ENV_CLIENT_SECRET && ENV_REFRESH_TOKEN); + + if (hasOAuthEnv || (ENV_AUTH_MODE === 'oauth' && hasOAuthEnv)) { + header('Credentials (from env)'); + info('Mode', 'encrypted_oauth'); + info('Integration ID', ENV_INTEGRATION_ID); + info('Client key', `<${ENV_CLIENT_KEY.length} chars>`); + return { + mode: 'encrypted_oauth', + integrationId: ENV_INTEGRATION_ID, + clientKeyShare: ENV_CLIENT_KEY, + }; + } + + if (hasSelfHostedEnv || ENV_AUTH_MODE === 'self_hosted') { + let clientId = ENV_CLIENT_ID; + let clientSecret = ENV_CLIENT_SECRET; + let refreshToken = ENV_REFRESH_TOKEN; + + if (!clientId || !clientSecret || !refreshToken) { + header('Credentials'); + clientId = clientId || (await prompt('Google Client ID')); + clientSecret = clientSecret || (await promptSecret('Google Client Secret')); + refreshToken = refreshToken || (await promptSecret('Refresh Token')); + if (!clientId || !clientSecret || !refreshToken) { + console.error(`\n${C.red} All three self-hosted fields are required.${C.reset}\n`); + process.exit(1); + } + } else { + header('Credentials (from env)'); + } + info('Mode', 'self_hosted'); + info('Client ID', `${clientId.slice(0, 12)}...`); + return { mode: 'self_hosted', clientId, clientSecret, refreshToken }; + } + + header('Authentication Mode'); + const choice = await prompt( + 'Auth mode — (1) Encrypted OAuth via browser (2) Own OAuth credentials', + '1' + ); + const mode = choice === '2' ? 'self_hosted' : 'encrypted_oauth'; + + if (mode === 'self_hosted') { + const clientId = await prompt('Google Client ID'); + const clientSecret = await promptSecret('Google Client Secret'); + const refreshToken = await promptSecret('Refresh Token'); + if (!clientId || !clientSecret || !refreshToken) { + console.error(`\n${C.red} All three fields are required.${C.reset}\n`); + process.exit(1); + } + return { mode: 'self_hosted', clientId, clientSecret, refreshToken }; + } + + header('OAuth Flow (browser)'); + step('Requesting OAuth URL from backend...'); + const connectUrl = `${BACKEND_URL}/auth/gmail/connect?skillId=gmail&responseType=json&encryptionMode=encrypted`; + const connectResp = await fetch(connectUrl, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + if (!connectResp.ok) { + const text = await connectResp.text(); + console.log(`${C.red}✗ Backend returned ${connectResp.status}: ${text}${C.reset}`); + process.exit(1); + } + const connectData = (await connectResp.json()) as { oauthUrl?: string }; + const oauthUrl = connectData.oauthUrl; + if (!oauthUrl) { + console.log(`${C.red}✗ No oauthUrl in response: ${JSON.stringify(connectData)}${C.reset}`); + process.exit(1); + } + ok(); + + console.log(`\n${C.yellow} Opening Google OAuth page in your browser...${C.reset}`); + console.log(`${C.dim} If it doesn't open, visit this URL manually:${C.reset}`); + console.log(`${C.dim} ${oauthUrl}${C.reset}\n`); + openUrl(oauthUrl); -if (!process.env.JWT_TOKEN || !INTEGRATION_ID || !CLIENT_KEY) { - console.error( - `\n${C.red} Missing env vars: JWT_TOKEN, GMAIL_INTEGRATION_ID, GMAIL_CLIENT_KEY_SHARE${C.reset}\n` + console.log( + `${C.yellow} After authorizing, copy integrationId and clientKey from the JSON response.${C.reset}\n` ); - process.exit(1); + + const integrationId = await prompt('Integration ID (24-char hex from callback)'); + const clientKeyShare = await promptSecret('Client key share (base64 from callback)'); + if (!integrationId || !clientKeyShare) { + console.error(`\n${C.red} Integration ID and client key share are required.${C.reset}\n`); + process.exit(1); + } + return { mode: 'encrypted_oauth', integrationId, clientKeyShare }; } // --------------------------------------------------------------------------- @@ -110,6 +288,8 @@ if (!process.env.JWT_TOKEN || !INTEGRATION_ID || !CLIENT_KEY) { async function main() { console.log(`\n${C.bold} Gmail Sync — Live Test${C.reset}`); + const creds = await resolveCredentials(); + // ── 1. Setup ────────────────────────────────────────────────────────── header('1. Setup'); @@ -127,20 +307,29 @@ async function main() { ok(`tools=${snap.tools.length} (${startMs}ms)`); step('OAuth...'); - const [, oauthMs] = await timed(() => - oauthComplete(SKILL_ID, { - credentialId: INTEGRATION_ID, - provider: 'gmail', - grantedScopes: [ - 'https://www.googleapis.com/auth/gmail.readonly', - 'https://www.googleapis.com/auth/gmail.send', - 'https://www.googleapis.com/auth/gmail.modify', - 'https://www.googleapis.com/auth/gmail.labels', - ], - clientKeyShare: CLIENT_KEY, - }) - ); - ok(`${oauthMs}ms`); + if (creds.mode === 'encrypted_oauth') { + const [, oauthMs] = await timed(() => + oauthComplete(SKILL_ID, { + credentialId: creds.integrationId, + provider: 'gmail', + grantedScopes: GRANTED_SCOPES, + clientKeyShare: creds.clientKeyShare, + }) + ); + ok(`${oauthMs}ms`); + } else { + const [, oauthMs] = await timed(async () => { + const result = (await authComplete(SKILL_ID, 'self_hosted', { + client_id: creds.clientId, + client_secret: creds.clientSecret, + refresh_token: creds.refreshToken, + })) as { status?: string; errors?: unknown }; + if (result.status !== 'complete') { + throw new Error(JSON.stringify(result.errors || result)); + } + }); + ok(`${oauthMs}ms`); + } step('Setup complete...'); const [, setupMs] = await timed(() => setSetupComplete(SKILL_ID, true)); diff --git a/src/core/gmail/sync.ts b/src/core/gmail/sync.ts index 56d62d4..e6ca5da 100644 --- a/src/core/gmail/sync.ts +++ b/src/core/gmail/sync.ts @@ -417,6 +417,8 @@ export function performInitialSync(onProgress?: SyncProgressCallback): void { log(`Initial sync complete: ${newEmails} new emails, ${skipped} skipped`, 100); + s.lastApiError = null; + // Ingest newly synced emails into knowledge graph ingestNewEmails(); @@ -496,6 +498,8 @@ export function onSync(): void { emitSyncProgress(`Sync complete: ${newEmails} new, ${skipped} skipped`, 100); console.log(`[gmail-sync] Incremental sync done: ${newEmails} new, ${skipped} skipped`); + s.lastApiError = null; + // Ingest newly synced emails into knowledge graph ingestNewEmails(); diff --git a/src/core/notion/helpers.ts b/src/core/notion/helpers.ts index b46b7e0..0ca9b82 100644 --- a/src/core/notion/helpers.ts +++ b/src/core/notion/helpers.ts @@ -34,8 +34,8 @@ function sleep(ms: number): void { * or the legacy OAuth bridge. * * Returns: - * - `{ type: 'token', token: string }` — direct API token (self_hosted or accessToken) - * - `{ type: 'proxy' }` — use oauth.fetch() server-side proxy + * - `{ type: 'token', token: string }` — direct API token (self_hosted / advanced auth only) + * - `{ type: 'proxy' }` — managed OAuth: all requests via `oauth.fetch()` (same as Gmail skill) * - `null` — not connected */ export function getNotionAuth(): { type: 'token'; token: string } | { type: 'proxy' } | null { @@ -49,12 +49,10 @@ export function getNotionAuth(): { type: 'token'; token: string } | { type: 'pro } } - // Check OAuth bridge (managed mode or legacy) + // Managed OAuth: always use backend proxy — do not call api.notion.com with a bearer token + // from the skill (matches Gmail `gmailFetch` → `oauth.fetch` only). const oauthCred = oauth.getCredential(); if (oauthCred) { - if (oauthCred.accessToken) { - return { type: 'token', token: oauthCred.accessToken as string }; - } return { type: 'proxy' }; } @@ -83,7 +81,7 @@ export function notionFetch( const t0 = Date.now(); if (notionAuth.type === 'token') { - // Direct Notion API call with token (self_hosted API token or OAuth accessToken) + // Direct Notion API call with integration token (self_hosted only) const url = `https://api.notion.com/v1${path}`; console.log(`[notion][fetch] ${method} ${url} (direct, attempt ${attempt})`); response = net.fetch(url, { @@ -97,10 +95,9 @@ export function notionFetch( timeout: 30, }); } else { - // Server-side OAuth proxy — the proxy validates against an allowlist of - // full paths (e.g. /v1/users, /v1/search), so we must include the /v1 prefix. - console.log(`[notion][fetch] ${method} /v1${path} (proxy, attempt ${attempt})`); - response = oauth.fetch(`/v1${path}`, { + // Path is relative to manifest `apiBaseUrl` (https://api.notion.com/v1), same as Gmail. + console.log(`[notion][fetch] ${method} ${path} (oauth.fetch proxy, attempt ${attempt})`); + response = oauth.fetch(path, { method, headers: { 'Content-Type': 'application/json', 'Notion-Version': apiVersion }, body: options.body ? JSON.stringify(options.body) : undefined, From 83087c485e2994e58604162f40532e1f6b0645ca Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 9 Apr 2026 01:23:11 +0530 Subject: [PATCH 2/3] Refactor Gmail live test sync and API for improved readability and consistency - Simplified the `ResolvedCreds` type definition for clarity. - Streamlined the `fetch` call in `resolveCredentials` for better readability. - Removed unnecessary whitespace in the Gmail API index file. These changes enhance code maintainability and improve overall readability without altering functionality. --- src/core/gmail/api/index.ts | 1 - src/core/gmail/live-test-sync.ts | 11 ++--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/core/gmail/api/index.ts b/src/core/gmail/api/index.ts index 2892533..befdb7f 100644 --- a/src/core/gmail/api/index.ts +++ b/src/core/gmail/api/index.ts @@ -17,7 +17,6 @@ function sleep(ms: number): void { } } - /** Returns true if any form of Gmail credential is available. */ export function isGmailConnected(): boolean { const authCred = auth.getCredential(); diff --git a/src/core/gmail/live-test-sync.ts b/src/core/gmail/live-test-sync.ts index b9236dc..1a8d76c 100644 --- a/src/core/gmail/live-test-sync.ts +++ b/src/core/gmail/live-test-sync.ts @@ -163,12 +163,7 @@ const GRANTED_SCOPES = [ type ResolvedCreds = | { mode: 'encrypted_oauth'; integrationId: string; clientKeyShare: string } - | { - mode: 'self_hosted'; - clientId: string; - clientSecret: string; - refreshToken: string; - }; + | { mode: 'self_hosted'; clientId: string; clientSecret: string; refreshToken: string }; /** Resolve JWT (required), then OAuth env or interactive — mirrors live-test.ts */ async function resolveCredentials(): Promise { @@ -247,9 +242,7 @@ async function resolveCredentials(): Promise { header('OAuth Flow (browser)'); step('Requesting OAuth URL from backend...'); const connectUrl = `${BACKEND_URL}/auth/gmail/connect?skillId=gmail&responseType=json&encryptionMode=encrypted`; - const connectResp = await fetch(connectUrl, { - headers: { Authorization: `Bearer ${jwt}` }, - }); + const connectResp = await fetch(connectUrl, { headers: { Authorization: `Bearer ${jwt}` } }); if (!connectResp.ok) { const text = await connectResp.text(); console.log(`${C.red}✗ Backend returned ${connectResp.status}: ${text}${C.reset}`); From 70ce4daee6e25b94ec092d0e018e53a3a0d16b64 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 9 Apr 2026 01:55:17 +0530 Subject: [PATCH 3/3] docs: add JSDoc for PR #11 CodeRabbit docstring coverage - Document serve-core helpers, gmailFetch/GmailApiResponse, live-test-sync CLI helpers - Document notionFetch, formatApiError, and Notion format* exports - Fix prefer-const in notionFetch error path; clarify Gmail API file header Made-with: Cursor --- scripts/serve-core.mjs | 10 ++++++++++ src/core/gmail/api/index.ts | 10 +++++++++- src/core/gmail/live-test-sync.ts | 21 +++++++++++++++++++++ src/core/notion/helpers.ts | 21 ++++++++++++++++++++- 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/scripts/serve-core.mjs b/scripts/serve-core.mjs index cc4ffb3..e16968d 100644 --- a/scripts/serve-core.mjs +++ b/scripts/serve-core.mjs @@ -17,6 +17,11 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = resolve(__dirname, '..'); +/** + * Parse `openhuman-skills/.env` into a copy of `process.env`, filling keys + * not already set in the environment (does not override existing vars). + * @returns {NodeJS.ProcessEnv} + */ function loadEnvFromSkillsDotenv() { const envPath = resolve(rootDir, '.env'); const envVars = { ...process.env }; @@ -39,6 +44,11 @@ function loadEnvFromSkillsDotenv() { return envVars; } +/** + * Locate the openhuman repo: `./openhuman` submodule, sibling `../openhuman`, or `OPENHUMAN_ROOT`. + * Exits the process if no checkout with `Cargo.toml` is found. + * @returns {string} Absolute path to the openhuman repository root + */ function findOpenhumanRoot() { const fromSubmodule = resolve(rootDir, 'openhuman', 'Cargo.toml'); if (existsSync(fromSubmodule)) { diff --git a/src/core/gmail/api/index.ts b/src/core/gmail/api/index.ts index befdb7f..6589f1c 100644 --- a/src/core/gmail/api/index.ts +++ b/src/core/gmail/api/index.ts @@ -1,5 +1,5 @@ // --------------------------------------------------------------------------- -// Gmail API helper (uses net.fetch directly with Bearer token) +// Gmail API helper (all requests via OAuth proxy — `oauth.fetch`) import { getGmailSkillState } from '../state'; import type { ApiError } from '../types'; @@ -25,12 +25,20 @@ export function isGmailConnected(): boolean { return !!oauthCred; } +/** Parsed Gmail API result: success payload or structured error for callers. */ export interface GmailApiResponse { success: boolean; data?: T; error?: ApiError; } +/** + * Perform a Gmail REST request through the managed OAuth proxy (`oauth.fetch`). + * Handles 429 backoff, rate-limit header updates, and optional raw batch bodies. + * + * @param endpoint - Path under the Gmail API root (leading `/` optional) + * @param options - HTTP method, body, headers, timeout, or `rawBatch` for multipart batch responses + */ export function gmailFetch( endpoint: string, options: { diff --git a/src/core/gmail/live-test-sync.ts b/src/core/gmail/live-test-sync.ts index 1a8d76c..a6a1d8c 100644 --- a/src/core/gmail/live-test-sync.ts +++ b/src/core/gmail/live-test-sync.ts @@ -48,13 +48,19 @@ const C = { blue: '\x1b[34m', }; +/** Print a cyan section header with horizontal rules. */ const header = (t: string) => console.log(`\n${C.cyan}${'─'.repeat(60)}\n ${t}\n${'─'.repeat(60)}${C.reset}`); +/** Print a blue in-progress step label (no newline). */ const step = (l: string) => process.stdout.write(`${C.blue} ▸ ${l}${C.reset} `); +/** Print a green checkmark with optional dim detail. */ const ok = (d?: string) => console.log(`${C.green}✓${C.reset}${d ? ` ${C.dim}${d}${C.reset}` : ''}`); +/** Print a red failure line. */ const fail = (d: string) => console.log(`${C.red}✗ ${d}${C.reset}`); +/** Print a dim key/value diagnostic line. */ const info = (l: string, v: unknown) => console.log(`${C.dim} ${l}: ${C.reset}${v}`); +/** Current time as `HH:MM:SS` for poll logs. */ const ts = () => new Date().toISOString().slice(11, 19); // --------------------------------------------------------------------------- @@ -63,6 +69,13 @@ const ts = () => new Date().toISOString().slice(11, 19); const SKILL_ID = 'gmail'; +/** + * Invoke a Gmail skill tool via the test harness and parse JSON/text from the result. + * + * @param name - Tool name + * @param args - Tool arguments + * @param timeoutMs - RPC timeout + */ async function callTool( name: string, args: Record = {}, @@ -87,6 +100,7 @@ async function callTool( } } +/** Read the skill’s published state snapshot from the harness (or null on error). */ async function getState(): Promise | null> { try { const snap = await getSkillStatus(SKILL_ID); @@ -96,6 +110,7 @@ async function getState(): Promise | null> { } } +/** Run an async function and return `[result, elapsedMs]`. */ function timed(fn: () => Promise): Promise<[T, number]> { const t0 = Date.now(); return fn().then(r => [r, Date.now() - t0]); @@ -107,6 +122,9 @@ function timed(fn: () => Promise): Promise<[T, number]> { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); +/** + * Prompt for a line of input; empty input falls back to `defaultValue` when provided. + */ function prompt(question: string, defaultValue?: string): Promise { const suffix = defaultValue ? ` ${C.dim}[${defaultValue}]${C.reset}` : ''; return new Promise(resolve => { @@ -116,6 +134,7 @@ function prompt(question: string, defaultValue?: string): Promise { }); } +/** Prompt for a hidden secret (raw TTY mode); Ctrl+C exits the process. */ function promptSecret(question: string): Promise { return new Promise(resolve => { process.stdout.write(`${C.yellow} ? ${question}: ${C.reset}`); @@ -142,6 +161,7 @@ function promptSecret(question: string): Promise { }); } +/** Open a URL in the default browser (best-effort; logs if the command fails). */ function openUrl(url: string) { const cmd = process.platform === 'darwin' @@ -278,6 +298,7 @@ async function resolveCredentials(): Promise { // Main // --------------------------------------------------------------------------- +/** CLI entry: resolve creds, start skill, OAuth, trigger sync, poll, verify tools, stop. */ async function main() { console.log(`\n${C.bold} Gmail Sync — Live Test${C.reset}`); diff --git a/src/core/notion/helpers.ts b/src/core/notion/helpers.ts index 0ca9b82..f16d9f2 100644 --- a/src/core/notion/helpers.ts +++ b/src/core/notion/helpers.ts @@ -64,6 +64,15 @@ export function isNotionConnected(): boolean { return getNotionAuth() !== null; } +/** + * Call the Notion API: direct `api.notion.com` with a bearer token (self-hosted) or + * `oauth.fetch` when using managed OAuth. Retries 429 and transient Cloudflare codes. + * + * @param endpoint - Path under `/v1` (leading `/` optional) + * @param options - Optional HTTP method and JSON body + * @returns Parsed JSON response body + * @throws Error on HTTP error responses or when retries are exhausted + */ export function notionFetch( endpoint: string, options: { method?: string; body?: unknown } = {} @@ -137,7 +146,7 @@ export function notionFetch( if (response.status >= 400) { const errorBody = response.body || ''; // Always include the raw body for debugging - let message = `Notion API error: ${response.status} — ${errorBody.slice(0, 300)}`; + const message = `Notion API error: ${response.status} — ${errorBody.slice(0, 300)}`; console.error('[notion][helpers] notionFetch error body:', errorBody); throw new Error(message); } @@ -152,6 +161,9 @@ export function notionFetch( ); } +/** + * Turn a thrown error or status string into a short, user-facing Notion message. + */ export function formatApiError(error: unknown): string { const message = String(error); @@ -193,6 +205,7 @@ export function formatApiError(error: unknown): string { // Formatting helpers // --------------------------------------------------------------------------- +/** Concatenate plain_text segments from a Notion rich_text array. */ export function formatRichText(richText: unknown[]): string { if (!Array.isArray(richText)) return ''; return richText @@ -203,6 +216,7 @@ export function formatRichText(richText: unknown[]): string { .join(''); } +/** Resolve a page’s display title from its `properties` title field, else its id. */ export function formatPageTitle(page: Record): string { const props = page.properties as Record; if (!props) return page.id as string; @@ -218,6 +232,7 @@ export function formatPageTitle(page: Record): string { return page.id as string; } +/** Compact page metadata for tools and UI (no full body). */ export function formatPageSummary(page: Record): Record { return { id: page.id, @@ -230,6 +245,7 @@ export function formatPageSummary(page: Record): Record): Record { const title = Array.isArray(db.title) ? formatRichText(db.title) : ''; return { @@ -242,6 +258,7 @@ export function formatDatabaseSummary(db: Record): Record): string { const type = block.type as string; const content = block[type] as Record | undefined; @@ -260,6 +277,7 @@ export function formatBlockContent(block: Record): string { return `[${type}]`; } +/** Id, type, children flag, and short content preview for a block. */ export function formatBlockSummary(block: Record): Record { return { id: block.id, @@ -269,6 +287,7 @@ export function formatBlockSummary(block: Record): Record): Record { // Default to top-level user fields let id = user.id as string;