mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat: implement Gmail metadata synchronization service
- Added a new file `metadataSync.ts` to handle the synchronization of Gmail profile and email summaries to the backend via the `integration:metadata-sync` socket event. - Updated import paths in `SkillProvider` to reflect the new location of the Gmail metadata synchronization function. - Enhanced type definitions for Gmail profile and email summaries to ensure proper data structure during synchronization.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
* `integration:metadata-sync` socket event so the server can merge them
|
||||
* into the user's Google OAuth integration metadata.
|
||||
*/
|
||||
import { emitViaRustSocket } from '../../utils/tauriSocket';
|
||||
import { emitViaRustSocket } from '../../../utils/tauriSocket';
|
||||
|
||||
const INTEGRATION_METADATA_SYNC_EVENT = 'integration:metadata-sync';
|
||||
const PROVIDER_GOOGLE = 'gmail';
|
||||
@@ -22,7 +22,7 @@ import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSli
|
||||
import {
|
||||
syncGmailMetadataToBackend,
|
||||
type GmailStateForSync,
|
||||
} from '../lib/skills/gmailMetadataSync';
|
||||
} from '../lib/gmail/services/metadataSync';
|
||||
import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -22,9 +22,6 @@ export const userApi = {
|
||||
* POST /settings/onboarding-complete
|
||||
*/
|
||||
onboardingComplete: async (): Promise<void> => {
|
||||
await apiClient.post<{ success: boolean; data: unknown }>(
|
||||
'/settings/onboarding-complete',
|
||||
{}
|
||||
);
|
||||
await apiClient.post<{ success: boolean; data: unknown }>('/settings/onboarding-complete', {});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ const handleOAuthDeepLink = async (parsed: URL) => {
|
||||
}
|
||||
|
||||
const encryptionKeyHex = state.auth.encryptionKeyByUser[userId];
|
||||
if (!encryptionKeyHex) {
|
||||
if (!encryptionKeyHex || typeof encryptionKeyHex !== 'string') {
|
||||
console.warn(
|
||||
'[DeepLink] Cannot fetch integration tokens: no encryption key found for user',
|
||||
userId
|
||||
@@ -98,8 +98,34 @@ const handleOAuthDeepLink = async (parsed: URL) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyForBackend = hexToBase64(encryptionKeyHex);
|
||||
const response = await fetchIntegrationTokens(integrationId, keyForBackend || encryptionKeyHex);
|
||||
const trimmedHex = encryptionKeyHex.trim().replace(/^0x/i, '');
|
||||
if (!trimmedHex || trimmedHex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(trimmedHex)) {
|
||||
console.error(
|
||||
'[DeepLink] Cannot fetch integration tokens: encryption key must be non-empty hex (even length, [0-9a-fA-F])',
|
||||
{ userId, encryptionKeyHex }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let keyForBackend: string;
|
||||
try {
|
||||
keyForBackend = hexToBase64(encryptionKeyHex);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'[DeepLink] Cannot fetch integration tokens: encryption key conversion failed',
|
||||
{ userId, encryptionKeyHex, error: e }
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!keyForBackend) {
|
||||
console.error(
|
||||
'[DeepLink] Cannot fetch integration tokens: encryption key produced empty base64',
|
||||
{ userId, encryptionKeyHex }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetchIntegrationTokens(integrationId, keyForBackend);
|
||||
if (!response.success || !response.data?.encrypted) {
|
||||
console.warn(
|
||||
'[DeepLink] Integration tokens response missing encrypted payload for integration',
|
||||
|
||||
@@ -12,13 +12,23 @@ export type IntegrationTokensPayload = {
|
||||
};
|
||||
|
||||
/** Stored value: encrypted blob only; decrypt with key when needed */
|
||||
export type StoredIntegrationTokens = {
|
||||
encrypted: string;
|
||||
};
|
||||
export type StoredIntegrationTokens = { encrypted: string };
|
||||
|
||||
const HEX_REGEX = /^[0-9a-fA-F]*$/;
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
const cleanHex = hex.trim().replace(/^0x/i, '');
|
||||
if (!cleanHex) return new Uint8Array();
|
||||
if (cleanHex.length % 2 !== 0) {
|
||||
throw new TypeError(
|
||||
`hexToBytes: hex string must have even length (got ${cleanHex.length})`
|
||||
);
|
||||
}
|
||||
if (!HEX_REGEX.test(cleanHex)) {
|
||||
throw new TypeError(
|
||||
'hexToBytes: hex string must contain only [0-9a-fA-F] characters'
|
||||
);
|
||||
}
|
||||
const bytes = new Uint8Array(cleanHex.length / 2);
|
||||
for (let i = 0; i < cleanHex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16);
|
||||
@@ -63,8 +73,8 @@ export async function decryptIntegrationTokens(
|
||||
}
|
||||
|
||||
const keyBytes = hexToBytes(keyHex);
|
||||
if (keyBytes.length === 0) {
|
||||
throw new Error('Invalid encryption key');
|
||||
if (keyBytes.length !== 32) {
|
||||
throw new Error('Invalid encryption key: expected 32-byte AES-GCM key');
|
||||
}
|
||||
|
||||
const combined = base64ToBytes(encryptedPayload);
|
||||
@@ -101,21 +111,36 @@ export async function decryptIntegrationTokens(
|
||||
* Matches backend: deterministic IV = first 16 bytes of SHA-256(message).
|
||||
* Returns base64(IV + AuthTag + EncryptedData).
|
||||
*/
|
||||
export async function encryptIntegrationTokens(
|
||||
plaintext: string,
|
||||
keyHex: string
|
||||
): Promise<string> {
|
||||
export async function encryptIntegrationTokens(plaintext: string, keyHex: string): Promise<string> {
|
||||
if (typeof crypto === 'undefined' || !crypto.subtle) {
|
||||
throw new Error('Web Crypto API is not available for encryption');
|
||||
}
|
||||
|
||||
const keyBytes = hexToBytes(keyHex);
|
||||
if (keyBytes.length === 0) {
|
||||
throw new Error('Invalid encryption key');
|
||||
if (keyBytes.length !== 32) {
|
||||
throw new Error('Invalid encryption key: expected 32-byte AES-GCM key');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(plaintext) as Record<string, unknown>;
|
||||
if (typeof payload.expiresAt !== 'string' || !payload.expiresAt.trim()) {
|
||||
throw new Error(
|
||||
'Payload must include a non-empty expiresAt field when using deterministic IV'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
throw new Error(
|
||||
'Plaintext must be JSON with a non-empty expiresAt field when using deterministic IV'
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const messageBytes = new TextEncoder().encode(plaintext);
|
||||
|
||||
// Deterministic IV for backend compatibility. TODO: Prefer a random 12-byte IV
|
||||
// (crypto.getRandomValues) prepended to ciphertext; update decrypt to handle both formats.
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', messageBytes);
|
||||
const iv = new Uint8Array(hashBuffer).slice(0, 16);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user