Files
AriWeb-Suite/hal/settings/catalog.mjs
T

95 lines
13 KiB
JavaScript

/**
* hal/settings/catalog.mjs — 통합 설정 관제(0.4.0, 개발 지시 v0.4.0 §2.1) 설정 목록(catalog).
*
* HAL 이 도입 모듈·Agent 의 "서버 단위" 설정을 대신 관리한다. 모듈별로 묶음(group)과 항목(key)을 선언하고, 항목마다
* type text · int · number · bool · enum · list(쉼표 구분 → 배열) · url · path · secret(비밀값 — Vault 에만, 화면·API 는 있음/없음만)
* scope 'both'(기본: 전체 값 + 서버 덮어쓰기) · 'server'(서버별 항목 — 전체 범위에 둘 수 없음)
* restart 값이 바뀌면 서비스 재시작이 필요한지(적용 작업의 안내·기본값)
* min/max/options/pattern 검증 규칙. 알 수 없는 항목·형식 오류는 저장하지 않는다(bad_setting).
* 모듈 쪽 적용 규칙(어느 파일·보관소에 넣는지)은 모듈 자신(Webmaker lib/hal-managed.js, AriMail server/hal-managed.mjs, Agent config.mjs)이 안다 — HAL 은 값만 보낸다.
* 도메인별 항목(AriMail LIMIT_* 등 메일 서버 사용자가 직접 관리하는 것)은 여기 없다.
*/
const K = (key, label, type, extra = {}) => ({ key, label, type, scope: 'both', ...extra });
const secret = (key, label, extra = {}) => K(key, label, 'secret', extra);
const en = (key, label, options, extra = {}) => K(key, label, 'enum', { options, ...extra });
const int = (key, label, min, max, extra = {}) => K(key, label, 'int', { min, max, ...extra });
const num = (key, label, min, max, extra = {}) => K(key, label, 'number', { min, max, ...extra });
const srv = (def) => ({ ...def, scope: 'server' });
const MODEL_REF = { pattern: '^[a-z0-9_-]+:.+$', hint: '<제공자>:<모델> (예: anthropic:claude-opus-5-5)' };
export const SETTINGS_CATALOG = {
webmaker: {
module: 'webmaker', label: 'Webmaker', file: 'hal-managed.json', configDir: '/data/webmaker', unit: 'webmaker', restartDefault: false,
description: 'Webmaker 관리 콘솔의 서버 단위 설정(API 키·마진·모델·서비스·서버별 설정)을 HAL 이 대신 관리한다. 사이트·회원·템플릿 등 콘텐츠 관리는 Webmaker 콘솔에 남는다.',
groups: [
{ id: 'auth', label: 'API 키·인증', keys: [
en('auth.mode', 'AI 연결 방식', ['server-login', 'oauth-token', 'api-key']), secret('auth.apiKey', 'Anthropic API 키'), secret('auth.oauthToken', 'Anthropic OAuth 토큰'),
secret('serviceApiKey', '서비스 키(X-Service-Key, 외부 캐시 조정)'), secret('backlogKey', '작업 반영 목록 읽기 키(X-Backlog-Key)'), secret('mail.apiKey', 'Resend API 키(사이트 메일 발송)'),
secret('source.token', 'Gitea API 토큰'), secret('ariweb.secret', 'Ariweb 포털 공유 비밀(SSO·서비스 호출 서명)'), secret('mail.adminPassword', 'AriMail 관리자 비밀번호(계정 생성·삭제용)'),
] },
{ id: 'pricing', label: '마진·가격 정책', keys: [
num('marginPct', '사용자 청구 마진 %', 0, 1000), en('fx.mode', '환율 방식', ['auto', 'manual']), num('fx.manualRate', '직접 입력 환율(1 USD 원화)', 0, 100000), num('fx.spreadPct', '환율 스프레드 %', 0, 50),
int('signupCredit', '신규 회원 기본 캐시(원)', 0, 100000000), int('maxEdits', '발행 전 수정 횟수 한도(0=무제한)', 0, 10000), en('defaultTier', '기본 등급', ['basic', 'standard', 'premium']),
K('tiers.basic', '기본 등급 모델', 'text', MODEL_REF), K('tiers.standard', '중급 등급 모델', 'text', MODEL_REF), K('tiers.premium', '고급 등급 모델', 'text', MODEL_REF), en('signupApproval', '회원가입 승인 방식', ['immediate', 'approval']),
] },
{ id: 'models', label: '모델', keys: [
K('internalRef', '내부 호출 모델', 'text', MODEL_REF), K('defaultModel', 'Claude 기본 모델 id', 'text', { pattern: '^[a-z0-9.-]+$' }), K('claudeModels', '사용할 Claude 모델(id 목록, 비우면 전체)', 'list'),
int('ai.claudeMaxTurns', 'Claude 최대 턴', 1, 500, { restart: true }), int('ai.openaiMaxTurns', 'OpenAI 호환 엔진 최대 턴', 1, 500, { restart: true }), int('ai.maxJobMinutes', 'AI 작업 최대 실행 시간(분)', 5, 240), int('ai.maxConcurrentJobs', '동시 AI 작업 수(서버 전체)', 1, 32),
en('ai.codexSandbox', 'codex 샌드박스', ['auto', 'workspace-write', 'none']),
] },
{ id: 'service', label: '전체 적용 서비스', keys: [
K('service.allowSignup', '회원가입 허용', 'bool'), K('letsEncryptEmail', "Let's Encrypt 알림 이메일", 'text'), K('mail.fromName', '발신 표시 이름', 'text'), K('mail.defaultDomain', '기본 발신 도메인', 'text'), K('mail.formBase', '폼 접수 기본 주소', 'url'),
K('ariweb.portalUrl', '포털 공개 주소', 'url'), K('endpoints.resendApiBase', 'Resend API', 'url'), K('endpoints.openproviderApiBase', 'Openprovider API', 'url'), K('endpoints.googleApiBase', 'Google API', 'url'), K('endpoints.googleTokenUrl', 'Google 토큰 URL', 'url'), K('endpoints.indexnowEndpoint', 'IndexNow', 'url'),
int('limits.copyMaxPages', '복사 최대 페이지 수', 1, 100000, { restart: true }), int('limits.copyMaxAssets', '복사 최대 자원 파일 수', 1, 1000000, { restart: true }), int('ssl.acmeWaitMin', '수동 DNS 대기(분)', 5, 600),
] },
{ id: 'server', label: '서버별 설정', scope: 'server', keys: [
srv(K('service.publicBaseUrl', '서비스 공개 주소', 'url')), srv(K('service.formBaseUrl', '폼 접수 서버 주소', 'url')), srv(K('service.siteOrigin', '생성 사이트 전용 주소', 'text')), srv(K('service.trustedProxies', '신뢰 프록시 IP(쉼표)', 'text')), srv(K('service.internalAllow', '내부 주소 허용 목록(host:port, 쉼표)', 'text')),
srv(K('ai.claudeBin', 'claude 실행 파일', 'path', { restart: true })), srv(K('ai.codexBin', 'codex 실행 파일', 'path', { restart: true })),
srv(en('source.provider', 'Source 연동', ['gitea', 'none'])), srv(K('source.remote', 'git 푸시 주소', 'text')), srv(K('source.web', 'Source 웹 주소', 'url')), srv(K('source.prefix', '브랜치 접두어', 'text')), srv(K('source.apiUrl', 'Gitea API 주소', 'url')), srv(K('source.user', 'Gitea 계정', 'text')), srv(K('source.repo', 'Gitea 저장소', 'text')),
srv(en('mail.provider', '메일 연동', ['none', 'arimail'])), srv(K('mail.url', 'AriMail 공개 주소', 'url')), srv(K('mail.apiUrl', 'AriMail API 주소(내부)', 'url')), srv(K('mail.domain', '메일 도메인', 'text')), srv(K('mail.adminLogin', 'AriMail 관리자 로그인 ID', 'text')), srv(K('mail.nickname', '기본 메일 서버 닉네임', 'text')), srv(K('mail.ip', '기본 메일 서버 IP', 'text')),
srv(K('ssl.acmeDir', 'ACME 작업 폴더', 'path', { restart: true })),
] },
],
},
arimail: {
module: 'arimail', label: 'AriMail', file: 'hal-managed.json', configDir: '<appDir>/data', unit: 'arimail', restartDefault: false,
description: 'AriMail 의 서버 단위 설정(수·발신 방식, Resend, DNS, SMTP 중계, Haraka, 표시, 사용량 API, Ariweb SSO)을 HAL 이 관리한다. 도메인별 제한·계정·메일함은 메일 서버 사용자(도메인 관리자)가 직접 관리한다.',
groups: [
{ id: 'mode', label: '수·발신 방식', keys: [en('inbound', '수신 방식', ['resend', 'haraka'], { restart: true }), en('outbound', '발신 방식', ['resend', 'haraka', 'smtp'], { restart: true })] },
{ id: 'resend', label: 'Resend', keys: [secret('RESEND_API_KEY', 'Resend API 키'), secret('RESEND_WEBHOOK_SECRET', 'Resend 웹훅 서명 비밀'), en('RESEND_REGION', 'Resend 지역', ['ap-northeast-1', 'us-east-1', 'eu-west-1', 'sa-east-1'])] },
{ id: 'dns', label: 'DNS 제공자', keys: [en('DNS_PROVIDER', 'DNS 제공자', ['none', 'google', 'route53']), secret('DNS_GOOGLE_CREDENTIALS', 'Google Cloud DNS 서비스 계정 JSON'), K('DNS_GOOGLE_PROJECT', 'Google 프로젝트', 'text'), K('DNS_GOOGLE_ZONE', 'Google DNS 존', 'text'), K('DNS_AWS_ACCESS_KEY_ID', 'AWS Access Key ID', 'text'), secret('DNS_AWS_SECRET_ACCESS_KEY', 'AWS Secret Access Key'), K('DNS_AWS_HOSTED_ZONE_ID', 'Route 53 Hosted Zone ID', 'text')] },
{ id: 'smtp', label: 'SMTP 중계', keys: [K('SMTP_RELAY_HOST', '중계 호스트', 'text'), int('SMTP_RELAY_PORT', '중계 포트', 1, 65535), en('SMTP_RELAY_SECURITY', '보안', ['starttls', 'tls', 'none']), K('SMTP_RELAY_USER', '중계 계정', 'text'), secret('SMTP_RELAY_PASS', '중계 비밀번호')] },
{ id: 'haraka', label: '내장 Haraka(서버별)', scope: 'server', keys: [srv(int('HARAKA_PORT', 'SMTP 수신 포트', 1, 65535, { restart: true })), srv(int('HARAKA_RELAY_PORT', '전달 포트', 1, 65535, { restart: true })), srv(K('HARAKA_HOSTNAME', '서버 호스트 이름', 'text', { restart: true })), srv(K('HARAKA_DKIM_SELECTOR', 'DKIM 선택자', 'text', { restart: true }))] },
{ id: 'site', label: '표시·동기화', keys: [K('MAIL_FROM_NAME', '발신 표시 이름', 'text'), K('SITE_TITLE', '사이트 제목', 'text'), K('MAILBOX_OWNER_LOGIN', '기본 메일함 소유 계정', 'text'), K('STATS_TIMEZONE', '통계 시간대', 'text'), int('SYNC_INTERVAL_MS', '동기화 주기(ms)', 60000, 86400000)] },
{ id: 'usage', label: '사용량 API', keys: [secret('USAGE_API_KEY', '사용량 API 키(Webmaker 가 호출)'), K('USAGE_API_ALLOW', '사용량 API 허용 IP(쉼표)', 'text')] },
{ id: 'ariweb', label: 'Ariweb 연동', keys: [secret('ARIWEB_SSO_SECRET', 'Ariweb SSO 공유 비밀')] },
],
},
agent: {
module: 'agent', label: 'Node Agent', file: 'hal-managed.json', configDir: '<dataDir>', unit: 'ariweb-agent', restartDefault: true,
description: '각 서버의 Node Agent 설정. 적용하면 Agent 가 결과를 보고한 뒤 스스로 재시작한다.',
groups: [{ id: 'agent', label: 'Agent', keys: [int('heartbeatMs', 'heartbeat 주기(ms)', 5000, 600000, { restart: true })] }],
},
};
export const MANAGED_MODULES = Object.keys(SETTINGS_CATALOG);
export const catalogFor = (module) => SETTINGS_CATALOG[module] || null;
const INDEX = new Map(MANAGED_MODULES.map((m) => [m, new Map(SETTINGS_CATALOG[m].groups.flatMap((g) => g.keys.map((k) => [k.key, { ...k, group: g.id }])))]));
export const keyDef = (module, key) => (INDEX.get(module) || new Map()).get(key) || null;
export const allKeys = (module) => [...(INDEX.get(module) || new Map()).values()];
/** 목록의 공개 형태(화면·AI 도구) */
export const publicCatalog = () => Object.fromEntries(MANAGED_MODULES.map((m) => { const c = SETTINGS_CATALOG[m]; return [m, { module: m, label: c.label, description: c.description, restartDefault: c.restartDefault, groups: c.groups.map((g) => ({ id: g.id, label: g.label, scope: g.scope || 'both', keys: g.keys.map((k) => ({ key: k.key, label: k.label, type: k.type, scope: k.scope, options: k.options, min: k.min, max: k.max, restart: !!k.restart, hint: k.hint })) })) }]; }));
/** 값 하나를 형식에 맞게 정규화한다. 실패하면 { code: 'bad_setting' } 오류 */
export function normalizeValue(def, raw) {
const bad = (why) => { throw Object.assign(new Error(`${def.label}(${def.key}): ${why}`), { code: 'bad_setting', status: 400, key: def.key }); };
if (raw === null || raw === undefined) return null;
switch (def.type) {
case 'secret': { const s = String(raw); return s.trim() === '' ? null : s; }
case 'bool': { if (typeof raw === 'boolean') return raw; const s = String(raw).trim().toLowerCase(); if (['true', '1', 'on', 'yes'].includes(s)) return true; if (['false', '0', 'off', 'no', ''].includes(s)) return false; return bad('참/거짓 값이어야 합니다'); }
case 'int': case 'number': { if (String(raw).trim() === '') return null; const n = Number(raw); if (!Number.isFinite(n)) return bad('숫자여야 합니다'); if (def.type === 'int' && !Number.isInteger(n)) return bad('정수여야 합니다'); if (def.min !== undefined && n < def.min) return bad(`${def.min} 이상이어야 합니다`); if (def.max !== undefined && n > def.max) return bad(`${def.max} 이하여야 합니다`); return n; }
case 'enum': { const s = String(raw).trim(); if (s === '') return null; if (!def.options.includes(s)) return bad(`허용 값: ${def.options.join(', ')}`); return s; }
case 'list': { const arr = Array.isArray(raw) ? raw : String(raw).split(',').map((x) => x.trim()); return arr.map(String).map((x) => x.trim()).filter(Boolean); }
case 'url': { const s = String(raw).trim(); if (s === '') return null; if (!/^https?:\/\/[^\s]+$/i.test(s)) return bad('http(s):// 주소여야 합니다'); return s; }
default: { const s = String(raw).trim(); if (s === '') return null; if (s.length > 4000) return bad('너무 깁니다'); if (def.pattern && !new RegExp(def.pattern).test(s)) return bad(def.hint || '형식이 올바르지 않습니다'); return s; }
}
}