* fix: always use business PSU type for bank connections EF (sole trader) users connecting to Nordea got personal accounts because psu_type was set to 'personal' based on entity_type. Since gnubok is accounting software, all bank connections should use 'business' PSU type regardless of entity type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: document inbox extension with AI classification Add invoice-inbox extension for email-based document processing with AI-powered classification, supplier matching, and inbox management. Includes MCP tools for document upload/listing, migration, and supporting changes across document service, API keys, and banking. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: disable invoice-inbox extension, use dynamic import in MCP server Keep invoice-inbox out of extensions.config.json until ready for production. MCP server now dynamically imports classifyDocument to avoid breaking when the extension is disabled. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: correct file size error message in invoice-inbox upload Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
77 lines
2.8 KiB
TypeScript
77 lines
2.8 KiB
TypeScript
import crypto from 'crypto'
|
|
|
|
const ALGORITHM = 'aes-256-gcm'
|
|
|
|
export function getGmailEncryptionKey(): Buffer {
|
|
const secret = process.env.GMAIL_TOKEN_ENCRYPTION_KEY
|
|
if (!secret) throw new Error('GMAIL_TOKEN_ENCRYPTION_KEY is required')
|
|
return crypto.createHash('sha256').update(secret).digest()
|
|
}
|
|
|
|
export function encryptState(payload: Record<string, unknown>): string {
|
|
const key = getGmailEncryptionKey()
|
|
const iv = crypto.randomBytes(12)
|
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
|
const json = JSON.stringify(payload)
|
|
const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])
|
|
const tag = cipher.getAuthTag()
|
|
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
|
|
}
|
|
|
|
export function decryptState(encoded: string): Record<string, unknown> | null {
|
|
try {
|
|
const key = getGmailEncryptionKey()
|
|
const combined = Buffer.from(encoded, 'base64url')
|
|
const iv = combined.subarray(0, 12)
|
|
const tag = combined.subarray(12, 28)
|
|
const encrypted = combined.subarray(28)
|
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
|
decipher.setAuthTag(tag)
|
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
|
|
return JSON.parse(decrypted.toString('utf8'))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function encryptToken(plaintext: string): string {
|
|
const key = getGmailEncryptionKey()
|
|
const iv = crypto.randomBytes(12)
|
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
|
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
|
const tag = cipher.getAuthTag()
|
|
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
|
|
}
|
|
|
|
export function decryptToken(encoded: string): string | null {
|
|
try {
|
|
const key = getGmailEncryptionKey()
|
|
const combined = Buffer.from(encoded, 'base64url')
|
|
const iv = combined.subarray(0, 12)
|
|
const tag = combined.subarray(12, 28)
|
|
const encrypted = combined.subarray(28)
|
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
|
decipher.setAuthTag(tag)
|
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
|
|
return decrypted.toString('utf8')
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function refreshAccessToken(refreshToken: string): Promise<string | null> {
|
|
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
refresh_token: refreshToken,
|
|
client_id: process.env.GOOGLE_CLIENT_ID!,
|
|
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
grant_type: 'refresh_token',
|
|
}),
|
|
})
|
|
if (!response.ok) return null
|
|
const data = await response.json() as { access_token: string }
|
|
return data.access_token
|
|
}
|