Files

172 lines
25 KiB
JavaScript

/**
* hal/ai/connections.mjs — AI 모델 연결(0.3.0, v0.1.4 §41.2). 로그인(로컬 CLI 세션)·OAuth·API 키 세 방식.
*
* 비밀값 규칙
* - API 키 → Vault `ai:conn:<id>:api_key`, OAuth 토큰 → `ai:conn:<id>:oauth`(JSON: accessToken·refreshToken·expiresAt·scope), OAuth client secret → `ai:conn:<id>:client_secret`.
* - DB 행(ai_connections)·공개 객체·목록·감사·대화 어디에도 비밀값이 없다. 공개 객체는 `secret: { hasKey, hint }` 처럼 "있음"과 힌트(앞 4자·뒤 2자)만 낸다.
* - 키 등록·교체·연결 삭제는 "인증 키 변경"이라 라우트(hal/ai/routes.mjs)가 기존 고위험 규칙(최근 10분 재인증)을 적용한다.
* 모델 선택: 공급자가 실제로 돌려준 모델 목록(models_json)이 있으면 그 밖의 모델은 `model_unavailable` 로 거부한다(조용히 다른 모델로 바꾸지 않음).
* OAuth: authorization code + PKCE(S256). state 는 해시로만 저장하고 10분 뒤 만료, 1회용. 시작한 운영자와 콜백 운영자가 같아야 한다(다른 계정에 토큰이 붙지 않게).
*/
import { HttpError } from '../../lib/http.mjs';
import { audit } from '../../lib/audit.mjs';
import { nowIso, toJson, parseJson } from '../../lib/db.mjs';
import { newId, randomToken, sha256 } from '../../lib/crypto.mjs';
import { hint, safeError } from '../../lib/redact.mjs';
import { createHash } from 'node:crypto';
import { PROVIDERS, providerKind, listModels as providerModels, ProviderError, CLI_TOOLS, cliDefaultModel } from './providers.mjs';
import { TOOL_PRESETS, presetTools, toolByName } from './tools.mjs';
import { ensureDirectRoom, removeRoomsOf } from './rooms.mjs';
const VK = { key: (id) => `ai:conn:${id}:api_key`, oauth: (id) => `ai:conn:${id}:oauth`, client: (id) => `ai:conn:${id}:client_secret`, state: (h) => `ai:oauth:${h}` };
const isUrl = (u, allowHttp = false) => { try { const x = new URL(u); return x.protocol === 'https:' || (allowHttp && x.protocol === 'http:'); } catch (_) { return false; } };
const clip = (s, n) => String(s ?? '').trim().slice(0, n);
const bad = (code, msg) => new HttpError(400, code, msg);
export function publicConnection(d, r) {
if (!r) return null; const oauth = parseJson(r.oauth_json, null); const kind = r.auth_kind;
const secret = kind === 'api_key' ? { hasKey: d.vault.has(VK.key(r.id)), hint: (d.vault.list().find((x) => x.name === VK.key(r.id)) || {}).hint || '' }
: kind === 'oauth' ? (() => { const t = parseJson(d.vault.has(VK.oauth(r.id)) ? d.vault.get(VK.oauth(r.id)) : null, null); return { hasToken: !!t, expiresAt: t ? t.expiresAt || null : null, hasRefresh: !!(t && t.refreshToken) }; })()
: { cli: true };
return { id: r.id, name: r.name, tabLabel: r.tab_label || null, provider: r.provider, providerLabel: (PROVIDERS[r.provider] || {}).label || r.provider, authKind: kind, baseUrl: r.base_url || null, model: r.model || null, models: parseJson(r.models_json, []) || [],
identity: parseJson(r.identity_json, {}) || {}, oauth: oauth ? { mode: oauth.mode || 'authorization_code', authorizeUrl: oauth.authorizeUrl, tokenUrl: oauth.tokenUrl, clientId: oauth.clientId, scopes: oauth.scopes || [], extra: oauth.extra || null, hasClientSecret: d.vault.has(VK.client(r.id)) } : null, cli: parseJson(r.cli_json, null),
tools: toolsOf(r), status: r.status, lastOkAt: r.last_ok_at, lastError: r.last_error, position: r.position, createdBy: r.created_by, createdAt: r.created_at, updatedAt: r.updated_at, secret, providerKind: providerKind(r.provider), toolsSupported: !!(PROVIDERS[r.provider] || {}).tools || (r.provider === 'local-cli' && toolsOf(r).enabled.length > 0), toolsVia: r.provider === 'local-cli' ? 'mcp' : 'api' };
}
/** [0.3.2] 도구 정책: tools_json 없으면 기본값 all(전부) */
function toolsOf(r) { const t = parseJson(r.tools_json, null); if (!t || !t.preset) return { preset: 'all', enabled: presetTools('all') }; return { preset: t.preset, enabled: Array.isArray(t.enabled) ? t.enabled : presetTools(t.preset) }; }
function checkTools(x) { if (!x || typeof x !== 'object') throw bad('bad_tools', '도구 정책은 객체여야 합니다'); const preset = String(x.preset || 'all'); if (preset === 'custom') { if (!Array.isArray(x.enabled)) throw bad('bad_tools', 'custom 은 enabled 목록이 필요합니다'); const bad1 = x.enabled.find((n) => !toolByName(n)); if (bad1) throw bad('unknown_tool', `모르는 도구: ${bad1}`); return { preset: 'custom', enabled: [...new Set(x.enabled)] }; } if (!TOOL_PRESETS.some((p) => p.id === preset)) throw bad('bad_tools', `모르는 프리셋: ${preset}`); return { preset, enabled: presetTools(preset) }; }
const row = (d, id) => d.store.get('SELECT * FROM ai_connections WHERE id=?', id) || null;
export function get(d, id) { return publicConnection(d, row(d, id)); }
export function list(d) { return d.store.all('SELECT * FROM ai_connections ORDER BY position, created_at').map((r) => publicConnection(d, r)); }
export const active = (d) => list(d).filter((c) => c.status === 'active');
function checkIdentity(x) { if (x == null) return {}; if (typeof x !== 'object' || Array.isArray(x)) throw bad('bad_identity', '고유 정보는 객체여야 합니다'); const out = {}; for (const k of ['role', 'traits', 'instructions']) if (x[k] != null) out[k] = clip(x[k], 2000); return out; }
/** OAuth 설정 검증. [0.3.2] 공급자 프리셋(gemini: Google 끝점·scope, openrouter: PKCE 키 발급)이 있으면 비운 값을 채운다. */
function checkOauth(x, provider = null) {
const preset = (PROVIDERS[provider] || {}).oauthPreset || null; x = { ...(x && typeof x === 'object' ? x : {}) };
if (preset) { if (!x.authorizeUrl) x.authorizeUrl = preset.authorizeUrl; if (!x.tokenUrl) x.tokenUrl = preset.tokenUrl; if (!Array.isArray(x.scopes) || !x.scopes.length) x.scopes = preset.scopes || []; if (preset.mode) x.mode = preset.mode; if (preset.extra) x.extra = preset.extra; }
if (x.mode === 'openrouter_pkce') return { mode: 'openrouter_pkce', authorizeUrl: x.authorizeUrl, tokenUrl: x.tokenUrl, clientId: clip(x.clientId, 200) || null, scopes: [] };
if (!isUrl(x.authorizeUrl) || !isUrl(x.tokenUrl)) throw bad('bad_oauth', 'OAuth authorize/token 주소(https)가 필요합니다'); if (!clip(x.clientId, 200)) throw bad('bad_oauth', 'clientId 가 필요합니다');
return { authorizeUrl: x.authorizeUrl, tokenUrl: x.tokenUrl, clientId: clip(x.clientId, 200), scopes: Array.isArray(x.scopes) ? x.scopes.map((s) => clip(s, 100)).filter(Boolean).slice(0, 30) : [], ...(x.extra && typeof x.extra === 'object' ? { extra: Object.fromEntries(Object.entries(x.extra).slice(0, 8).map(([k, v]) => [clip(k, 40), clip(v, 100)])) } : {}) };
}
function checkCli(x) { if (x == null) return null; if (typeof x !== 'object') throw bad('bad_cli', 'CLI 설정은 객체여야 합니다'); const out = {}; if (x.tool != null && x.tool !== '') { if (!CLI_TOOLS[x.tool]) throw bad('bad_cli', `지원하지 않는 CLI 종류: ${x.tool} (${Object.keys(CLI_TOOLS).join('·')})`); out.tool = x.tool; } if (x.bin != null) { const b = clip(x.bin, 400); if (b && !b.startsWith('/')) throw bad('bad_cli', 'CLI 실행 파일은 절대 경로여야 합니다'); out.bin = b || undefined; } if (Array.isArray(x.args)) out.args = x.args.map((a) => clip(a, 200)).filter(Boolean).slice(0, 20); if (x.timeoutMs != null) out.timeoutMs = Math.min(3600000, Math.max(10000, Number(x.timeoutMs) || 600000)); return out; }
function nameFree(d, name, exceptId = null) { const r = d.store.get('SELECT id FROM ai_connections WHERE lower(name)=lower(?)', name); return !r || r.id === exceptId; }
/** 연결 만들기. 비밀값(apiKey·oauth.clientSecret)은 Vault 로만 가고 행·감사에는 들어가지 않는다. */
export function create(d, b, by) {
const name = clip(b.name, 60); if (!name) throw bad('bad_name', '이름이 필요합니다(1~60자)'); if (!nameFree(d, name)) throw new HttpError(409, 'name_taken', '같은 이름의 연결이 있습니다');
const prov = PROVIDERS[b.provider]; if (!prov) throw bad('bad_provider', `알 수 없는 공급자: ${b.provider}`); const authKind = String(b.authKind || ''); if (!prov.authKinds.includes(authKind)) throw bad('bad_auth_kind', `${prov.label} 는 ${prov.authKinds.join('·')} 방식만 지원합니다`);
if (b.baseUrl != null && b.baseUrl !== '' && !isUrl(b.baseUrl, true)) throw bad('bad_base_url', 'baseUrl 은 http(s) 주소여야 합니다');
if (b.provider === 'other' && !b.baseUrl) throw bad('base_url_required', '기타 API 서비스는 기본 주소(…/v1)가 필요합니다'); // [0.3.2] 프리셋이 없는 서비스는 주소 필수
const identity = checkIdentity(b.identity); const oauth = authKind === 'oauth' ? checkOauth(b.oauth, b.provider) : null; const tools = b.tools !== undefined ? checkTools(b.tools) : { preset: 'all', enabled: presetTools('all') }; const cli = b.provider === 'local-cli' ? checkCli(b.cli) || {} : null;
if (cli && !cli.tool) cli.tool = cli.bin && CLI_TOOLS[cli.bin.split('/').pop()] ? cli.bin.split('/').pop() : 'claude'; // [0.3.1] CLI 종류: 지정 → 실행 파일 이름 → claude
const model = clip(b.model, 120) || (cli ? cliDefaultModel(cli.tool) : ''); if (!model) throw bad('bad_model', '기본 모델이 필요합니다');
if (authKind === 'api_key' && (typeof b.apiKey !== 'string' || b.apiKey.trim().length < 8)) throw bad('bad_api_key', 'API 키가 필요합니다');
const id = newId('aic_'); const pos = (d.store.get('SELECT COALESCE(MAX(position),0) AS p FROM ai_connections').p || 0) + 1; const status = authKind === 'oauth' ? 'reauth_required' : 'active';
d.store.run('INSERT INTO ai_connections(id, name, tab_label, provider, auth_kind, base_url, model, models_json, identity_json, oauth_json, cli_json, tools_json, status, position, created_by, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', id, name, null, b.provider, authKind, b.baseUrl || null, model, '[]', toJson(identity), oauth ? toJson(oauth) : null, cli ? toJson(cli) : null, toJson(tools), status, pos, by || null, nowIso(), nowIso());
if (authKind === 'api_key') d.vault.set(VK.key(id), b.apiKey.trim(), { kind: 'ai-api-key' }); if (oauth && b.oauth && typeof b.oauth.clientSecret === 'string' && b.oauth.clientSecret) d.vault.set(VK.client(id), b.oauth.clientSecret, { kind: 'ai-oauth-client' });
const c = get(d, id); ensureDirectRoom(d, c);
audit(d.store, { actorType: 'operator', actorId: by, action: 'ai.connection.create', subjectType: 'ai_connection', subjectId: id, detail: { name, provider: b.provider, authKind, model, hasIdentity: !!Object.keys(identity).length } });
return c;
}
export function update(d, id, p, by) {
const r = row(d, id); if (!r) throw new HttpError(404, 'connection_not_found', '연결이 없습니다'); const sets = {}; const changed = [];
if (p.name != null) { const n = clip(p.name, 60); if (!n) throw bad('bad_name', '이름이 필요합니다'); if (!nameFree(d, n, id)) throw new HttpError(409, 'name_taken', '같은 이름의 연결이 있습니다'); sets.name = n; }
if (p.tabLabel !== undefined) sets.tab_label = clip(p.tabLabel, 60) || null;
if (p.model != null) { const m = clip(p.model, 120); const known = parseJson(r.models_json, []) || []; if (known.length && !known.includes(m)) throw bad('model_unavailable', `공급자가 제공하지 않는 모델입니다: ${m}. 사용 가능: ${known.slice(0, 20).join(', ')}`); sets.model = m; }
if (p.identity !== undefined) sets.identity_json = toJson(checkIdentity(p.identity));
if (p.baseUrl !== undefined) { if (p.baseUrl && !isUrl(p.baseUrl, true)) throw bad('bad_base_url', 'baseUrl 은 http(s) 주소여야 합니다'); sets.base_url = p.baseUrl || null; }
if (p.tools !== undefined) sets.tools_json = toJson(checkTools(p.tools)); // [0.3.2] 도구 정책
if (p.oauth !== undefined && r.auth_kind === 'oauth') { sets.oauth_json = toJson(checkOauth({ ...parseJson(r.oauth_json, {}), ...p.oauth }, r.provider)); if (typeof p.oauth.clientSecret === 'string' && p.oauth.clientSecret) { d.vault.set(VK.client(id), p.oauth.clientSecret, { kind: 'ai-oauth-client' }); changed.push('clientSecret'); } }
if (p.cli !== undefined && r.provider === 'local-cli') sets.cli_json = toJson({ ...(parseJson(r.cli_json, {}) || {}), ...(checkCli(p.cli) || {}) });
if (p.status != null) { if (!['active', 'disabled'].includes(p.status)) throw bad('bad_status', '상태는 active 또는 disabled 만 바꿀 수 있습니다'); if (p.status === 'active' && r.auth_kind === 'oauth' && !d.vault.has(VK.oauth(id))) throw new HttpError(409, 'oauth_required', 'OAuth 로그인(재인증)이 먼저 필요합니다'); sets.status = p.status; }
if (p.position != null) sets.position = Math.max(1, Number(p.position) || 1);
const keys = Object.keys(sets); if (keys.length) d.store.run(`UPDATE ai_connections SET ${keys.map((k) => `${k}=?`).join(', ')}, updated_at=? WHERE id=?`, ...keys.map((k) => sets[k]), nowIso(), id);
audit(d.store, { actorType: 'operator', actorId: by, action: 'ai.connection.update', subjectType: 'ai_connection', subjectId: id, detail: { fields: [...keys, ...changed] } });
return get(d, id);
}
export function remove(d, id, by) {
const r = row(d, id); if (!r) throw new HttpError(404, 'connection_not_found', '연결이 없습니다');
removeRoomsOf(d, id); d.store.run('DELETE FROM ai_oauth_states WHERE connection_id=?', id); d.store.run('DELETE FROM ai_connections WHERE id=?', id);
for (const n of [VK.key(id), VK.oauth(id), VK.client(id)]) d.vault.remove(n);
audit(d.store, { actorType: 'operator', actorId: by, action: 'ai.connection.delete', subjectType: 'ai_connection', subjectId: id, detail: { name: r.name, provider: r.provider } });
return { deleted: id };
}
export function setSecret(d, id, apiKey, by) {
const r = row(d, id); if (!r) throw new HttpError(404, 'connection_not_found', '연결이 없습니다'); if (r.auth_kind !== 'api_key') throw bad('bad_auth_kind', 'API 키 방식 연결이 아닙니다');
if (typeof apiKey !== 'string' || apiKey.trim().length < 8) throw bad('bad_api_key', 'API 키가 필요합니다');
d.vault.set(VK.key(id), apiKey.trim(), { kind: 'ai-api-key' }); d.store.run('UPDATE ai_connections SET status=CASE WHEN status=\'disabled\' THEN status ELSE \'active\' END, last_error=NULL, updated_at=? WHERE id=?', nowIso(), id);
audit(d.store, { actorType: 'operator', actorId: by, action: 'ai.connection.secret', subjectType: 'ai_connection', subjectId: id, detail: { replaced: true } });
return get(d, id);
}
function setStatus(d, id, status, lastError = null, ok = false) { d.store.run('UPDATE ai_connections SET status=CASE WHEN status=\'disabled\' THEN status ELSE ? END, last_error=?, last_ok_at=CASE WHEN ? THEN ? ELSE last_ok_at END, updated_at=? WHERE id=?', status, lastError, ok ? 1 : 0, nowIso(), nowIso(), id); }
/** 공급자 호출용 자격. api_key → {kind:'api_key'} / oauth → {kind:'bearer'}(만료면 refresh) / login → null(CLI 가 스스로 세션을 쓴다) */
export async function secretFor(d, conn) {
if (conn.authKind === 'api_key') { const v = d.vault.get(VK.key(conn.id)); if (!v) throw new ProviderError('no_credential', 'API 키가 등록되어 있지 않습니다', { status: 409 }); return { kind: 'api_key', value: v }; }
if (conn.authKind === 'oauth') return { kind: 'bearer', value: await bearerFor(d, conn) };
return null;
}
async function bearerFor(d, conn) {
const raw = d.vault.has(VK.oauth(conn.id)) ? d.vault.get(VK.oauth(conn.id)) : null; const tok = parseJson(raw, null);
if (!tok || !tok.accessToken) { setStatus(d, conn.id, 'reauth_required', 'OAuth 로그인이 필요합니다'); throw new ProviderError('oauth_required', 'OAuth 로그인(재인증)이 필요합니다', { status: 409 }); }
if (tok.expiresAt && Date.parse(tok.expiresAt) - 60000 < Date.now()) {
if (!tok.refreshToken || conn.oauth.mode === 'openrouter_pkce') { setStatus(d, conn.id, 'reauth_required', 'OAuth 토큰 만료'); throw new ProviderError('oauth_expired', 'OAuth 토큰이 만료되었습니다 — 다시 로그인하세요', { status: 409 }); }
const o = conn.oauth; const form = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tok.refreshToken, client_id: o.clientId }); if (d.vault.has(VK.client(conn.id))) form.set('client_secret', d.vault.get(VK.client(conn.id)));
const r = await (d.fetch || fetch)(o.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: form.toString(), signal: AbortSignal.timeout(20000) }); let j = null; try { j = await r.json(); } catch (_) {}
if (!r.ok || !j || !j.access_token) { setStatus(d, conn.id, 'reauth_required', 'OAuth 토큰 갱신 실패'); throw new ProviderError('oauth_refresh_failed', 'OAuth 토큰 갱신에 실패했습니다 — 다시 로그인하세요', { status: 409 }); }
const nt = { accessToken: j.access_token, refreshToken: j.refresh_token || tok.refreshToken, expiresAt: j.expires_in ? new Date(Date.now() + Number(j.expires_in) * 1000).toISOString() : null, scope: j.scope || tok.scope || null, tokenType: j.token_type || 'Bearer' };
d.vault.set(VK.oauth(conn.id), JSON.stringify(nt), { kind: 'ai-oauth-token' }); return nt.accessToken;
}
return tok.accessToken;
}
/** 연결 확인: 실제 공급자 모델 목록(또는 CLI 실행 파일)을 확인해 models·상태·마지막 성공·오류를 갱신한다. */
export async function testConnection(d, id, by) {
const conn = get(d, id); if (!conn) throw new HttpError(404, 'connection_not_found', '연결이 없습니다');
try {
const secret = await secretFor(d, conn); const r = await providerModels(d, conn, secret);
d.store.run('UPDATE ai_connections SET models_json=?, updated_at=? WHERE id=?', toJson(r.models || []), nowIso(), id); setStatus(d, id, 'active', null, true);
// [0.3.1] 로컬 CLI: 탐색한 실행 파일·버전·로그인 상태를 연결에 남긴다(화면 표시, 다음 실행은 같은 파일). 운영자가 bin 을 직접 적었으면 그대로 둔다.
if (r.cli) { const prev = parseJson(row(d, id).cli_json, {}) || {}; d.store.run('UPDATE ai_connections SET cli_json=? WHERE id=?', toJson({ ...prev, tool: r.cli.tool, bin: prev.bin || r.cli.bin, version: r.cli.version, loggedIn: r.cli.loggedIn, loginMethod: r.cli.loginMethod, checkedAt: nowIso() }), id); }
audit(d.store, { actorType: 'operator', actorId: by, action: 'ai.connection.test', subjectType: 'ai_connection', subjectId: id, outcome: 'ok', detail: { models: (r.models || []).length, cliVersion: r.cliVersion || null, loggedIn: r.loggedIn ?? null } });
return { models: r.models || [], cliVersion: r.cliVersion || null, loggedIn: r.loggedIn ?? null, connection: get(d, id) };
} catch (e) {
const msg = safeError(e); const code = e.code || 'provider_error'; setStatus(d, id, ['oauth_required', 'oauth_expired', 'provider_401', 'cli_not_logged_in'].includes(code) ? 'reauth_required' : 'error', msg);
audit(d.store, { actorType: 'operator', actorId: by, action: 'ai.connection.test', subjectType: 'ai_connection', subjectId: id, outcome: 'failed', detail: { code, message: msg } });
throw new HttpError(e.status && e.status < 500 && e.status !== 409 ? e.status : 424, code, msg); // 424(의존성 실패): 5xx 로 내면 화면에 메시지가 숨겨지므로(일시적 오류 문구) 원인을 그대로 보여 준다
}
}
// ---------------------------------------------------------------- OAuth(PKCE)
const b64url = (buf) => Buffer.from(buf).toString('base64url');
export const redirectUri = (d) => `${String(d.config.publicUrl || '').replace(/\/+$/, '')}/ops/ai/oauth/callback`;
export function oauthStart(d, id, { operatorId }) {
const conn = get(d, id); if (!conn) throw new HttpError(404, 'connection_not_found', '연결이 없습니다'); if (conn.authKind !== 'oauth' || !conn.oauth) throw bad('bad_auth_kind', 'OAuth 방식 연결이 아닙니다');
const state = randomToken(24); const verifier = randomToken(48); const challenge = b64url(createHash('sha256').update(verifier).digest()); const h = sha256(state);
d.store.run('DELETE FROM ai_oauth_states WHERE expires_at<?', nowIso());
d.store.run('INSERT INTO ai_oauth_states(state_hash, connection_id, operator_id, created_at, expires_at) VALUES(?,?,?,?,?)', h, id, operatorId, nowIso(), new Date(Date.now() + 10 * 60000).toISOString()); d.vault.set(VK.state(h), verifier, { kind: 'ai-oauth-pkce' });
const u = new URL(conn.oauth.authorizeUrl);
if (conn.oauth.mode === 'openrouter_pkce') { u.searchParams.set('callback_url', `${redirectUri(d)}?state=${encodeURIComponent(state)}`); u.searchParams.set('code_challenge', challenge); u.searchParams.set('code_challenge_method', 'S256'); } // OpenRouter: state 는 callback_url 안에 실어 돌려받는다
else { u.searchParams.set('response_type', 'code'); u.searchParams.set('client_id', conn.oauth.clientId); u.searchParams.set('redirect_uri', redirectUri(d)); if (conn.oauth.scopes.length) u.searchParams.set('scope', conn.oauth.scopes.join(' ')); u.searchParams.set('state', state); u.searchParams.set('code_challenge', challenge); u.searchParams.set('code_challenge_method', 'S256'); for (const [k, v] of Object.entries(conn.oauth.extra || {})) u.searchParams.set(k, v); }
audit(d.store, { actorType: 'operator', actorId: operatorId, action: 'ai.connection.oauth.start', subjectType: 'ai_connection', subjectId: id, detail: { authorizeHost: u.host } });
return { authorizeUrl: u.toString(), expiresInSec: 600 };
}
export async function oauthCallback(d, { code, state, operatorId }) {
if (!code || !state) throw bad('oauth_bad_callback', 'code·state 가 필요합니다'); const h = sha256(String(state)); const st = d.store.get('SELECT * FROM ai_oauth_states WHERE state_hash=?', h);
if (!st) throw bad('oauth_state_invalid', 'OAuth state 가 올바르지 않거나 이미 사용되었습니다'); if (Date.parse(st.expires_at) < Date.now()) { d.store.run('DELETE FROM ai_oauth_states WHERE state_hash=?', h); d.vault.remove(VK.state(h)); throw bad('oauth_state_expired', 'OAuth 로그인 시간이 지났습니다 — 다시 시작하세요'); }
if (st.operator_id !== operatorId) { audit(d.store, { actorType: 'operator', actorId: operatorId, action: 'ai.connection.oauth.callback', subjectType: 'ai_connection', subjectId: st.connection_id, outcome: 'denied', detail: { reason: 'operator_mismatch' } }); throw bad('oauth_state_mismatch', '이 OAuth 로그인은 다른 운영자 세션에서 시작되었습니다'); }
d.store.run('DELETE FROM ai_oauth_states WHERE state_hash=?', h); const verifier = d.vault.has(VK.state(h)) ? d.vault.get(VK.state(h)) : null; d.vault.remove(VK.state(h));
const conn = get(d, st.connection_id); if (!conn || !conn.oauth || !verifier) throw bad('oauth_state_invalid', 'OAuth 상태를 복원할 수 없습니다');
let j = null; let status = 0;
if (conn.oauth.mode === 'openrouter_pkce') { // OpenRouter: code + code_verifier → API 키(JSON). 키를 Bearer 토큰처럼 Vault 에 둔다
try { const r = await (d.fetch || fetch)(conn.oauth.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ code: String(code), code_verifier: verifier, code_challenge_method: 'S256' }), signal: AbortSignal.timeout(20000) }); status = r.status; try { const k = await r.json(); if (k && k.key) j = { access_token: k.key, token_type: 'Bearer' }; } catch (_) {} } catch (e) { j = null; }
} else {
const form = new URLSearchParams({ grant_type: 'authorization_code', code: String(code), redirect_uri: redirectUri(d), client_id: conn.oauth.clientId, code_verifier: verifier }); if (d.vault.has(VK.client(conn.id))) form.set('client_secret', d.vault.get(VK.client(conn.id)));
try { const r = await (d.fetch || fetch)(conn.oauth.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: form.toString(), signal: AbortSignal.timeout(20000) }); status = r.status; try { j = await r.json(); } catch (_) {} } catch (e) { j = null; }
}
if (!j || !j.access_token) { setStatus(d, conn.id, 'reauth_required', `OAuth 토큰 교환 실패(${status || '연결 실패'})`); audit(d.store, { actorType: 'operator', actorId: operatorId, action: 'ai.connection.oauth.callback', subjectType: 'ai_connection', subjectId: conn.id, outcome: 'failed', detail: { status } }); throw new HttpError(502, 'oauth_token_failed', `OAuth 토큰 교환에 실패했습니다(${status || '연결 실패'})`); }
const tok = { accessToken: j.access_token, refreshToken: j.refresh_token || null, expiresAt: j.expires_in ? new Date(Date.now() + Number(j.expires_in) * 1000).toISOString() : null, scope: j.scope || conn.oauth.scopes.join(' ') || null, tokenType: j.token_type || 'Bearer' };
d.vault.set(VK.oauth(conn.id), JSON.stringify(tok), { kind: 'ai-oauth-token' }); setStatus(d, conn.id, 'active', null, true);
audit(d.store, { actorType: 'operator', actorId: operatorId, action: 'ai.connection.oauth.callback', subjectType: 'ai_connection', subjectId: conn.id, outcome: 'ok', detail: { expiresAt: tok.expiresAt, hasRefresh: !!tok.refreshToken } });
return get(d, conn.id);
}