Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
90 lines
7.7 KiB
JavaScript
90 lines
7.7 KiB
JavaScript
/**
|
|
* portal/lifecycle/common.mjs — 백업·복원·이전·소유권 이전·생명주기 영역(fork F)이 함께 쓰는 도우미.
|
|
*
|
|
* - getSite / getMailService : 그룹 경계와 사이트별 권한(§4.3)을 검사해 행을 돌려준다(다른 그룹 것은 404 — 존재를 드러내지 않음 §37).
|
|
* - opsGuard : 운영자 콘솔(HAL)에서 오는 내부 호출을 lib/serviceauth 로 검증한다(보관소의 'service:<keyId>').
|
|
* - productPolicy : 활성 상품정책 버전(product_versions)에서 limits/policy 를 읽는다. 값이 없으면 deferred 로 표시해 화면이 "미확정" 을 솔직히 보이게 한다(§19.1·§27.2).
|
|
* - halOf : HAL 어댑터가 없거나 응답하지 않으면 '정보 확인 지연' 오류(503)로 바꿔 신규 증가를 막는다(§13.4·§26.2).
|
|
* - inProgress : 사이트·메일에 진행 중인 배포·복원·이전·소유권 이전이 있는지(충돌 확인 §11.1·§21·§22).
|
|
* - 설정 표(settings) 읽기/쓰기, KST 표시, 작업 상태 요약.
|
|
*/
|
|
import { HttpError } from '../../lib/http.mjs';
|
|
import { requireSitePerm, requireGroup } from '../auth/guard.mjs';
|
|
import { verifyCtx } from '../../lib/serviceauth.mjs';
|
|
import { parseJson, toJson, nowIso } from '../../lib/db.mjs';
|
|
import { TERMINAL } from '../../lib/status.mjs';
|
|
import { describe } from '../../lib/jobs.mjs';
|
|
import { fmtKst } from '../../lib/kst.mjs';
|
|
|
|
export { fmtKst, nowIso, parseJson, toJson, describe };
|
|
/** ms 단위 상수 */
|
|
export const DAY = 86400000, HOUR = 3600000;
|
|
export const addMs = (iso, ms) => new Date(Date.parse(iso || nowIso()) + ms).toISOString();
|
|
|
|
/** 사이트 행 + 권한 검사 */
|
|
export function getSite(deps, ctx, id, perm = 'view') {
|
|
const s = deps.store.get('SELECT * FROM sites WHERE id=?', id);
|
|
requireSitePerm(ctx, s, perm); // 없거나 다른 그룹이면 404
|
|
return s;
|
|
}
|
|
/** 메일 서비스 행 + 권한 검사(사이트 대표 메일은 사이트 권한 domain_mail, 독립 메일은 그룹 역할) */
|
|
export function getMailService(deps, ctx, id, perm = 'view') {
|
|
requireGroup(ctx);
|
|
const m = deps.store.get('SELECT * FROM mail_services WHERE id=? AND group_id=?', id, ctx.group.id);
|
|
if (!m) throw new HttpError(404, 'not_found', '요청한 항목을 찾을 수 없습니다');
|
|
if (m.site_id) { const s = deps.store.get('SELECT * FROM sites WHERE id=?', m.site_id); if (s) requireSitePerm(ctx, s, perm === 'view' ? 'view' : 'domain_mail'); }
|
|
return m;
|
|
}
|
|
/** 운영자 콘솔(HAL) 내부 호출 검증. 통과하면 keyId. */
|
|
export async function opsGuard(ctx, deps) {
|
|
const r = await verifyCtx(ctx, (keyId) => { try { return deps.vault.get('service:' + keyId); } catch (_) { return null; } });
|
|
if (!r.ok) throw new HttpError(401, 'service_auth_failed', `서비스 인증에 실패했습니다 (${r.reason})`);
|
|
return r.keyId;
|
|
}
|
|
/** 운영자 식별(내부 호출 본문의 operator 필드) */
|
|
export const operatorOf = (body) => String((body && body.operator) || 'operator').slice(0, 80);
|
|
|
|
/**
|
|
* 활성 상품정책. productId 의 published 버전 중 effective_from ≤ now < effective_to 인 가장 높은 버전.
|
|
* @returns {{version:object|null, limits:object, policy:object, deferred:string[]}}
|
|
*/
|
|
export function productPolicy(deps, productId, required = []) {
|
|
const now = nowIso();
|
|
const v = deps.store.get('SELECT * FROM product_versions WHERE product_id=? AND published=1 AND effective_from<=? AND (effective_to IS NULL OR effective_to>?) ORDER BY version DESC LIMIT 1', productId, now, now) || null;
|
|
const limits = parseJson(v?.limits_json, {}) || {}; const policy = parseJson(v?.policy_json, {}) || {};
|
|
const deferred = required.filter((k) => { const [bag, key] = k.split('.'); const src = bag === 'limits' ? limits : policy; return src[key] === undefined || src[key] === null || src[key] === ''; });
|
|
return { version: v, limits, policy, deferred, priceKrw: v ? v.price : null, currency: v ? v.currency : 'KRW', taxRate: v ? v.tax_rate : 0.1 };
|
|
}
|
|
/** HAL 어댑터. 없으면 503 '정보 확인 지연' */
|
|
export function halOf(deps) {
|
|
if (!deps.hal) throw new HttpError(503, 'hal_unavailable', '중앙 관리 기능(HAL)과 연결되지 않아 지금은 새 작업을 시작할 수 없습니다. 기존 서비스는 계속 제공되며 연결이 복구되면 다시 확인합니다', { state: 'stale' });
|
|
return deps.hal;
|
|
}
|
|
/** HAL 호출 오류를 작업 엔진의 일시 오류(재시도)로 바꾼다 */
|
|
export function asTransient(e) { if (e && e.code === 'service_unreachable') { e.retryable = true; } return e; }
|
|
/** 대상(사이트·메일)에 진행 중인 작업 목록 — 배포·복원·이전·소유권 이전 */
|
|
export function inProgress(deps, subjectType, subjectId) {
|
|
const out = [];
|
|
const jobs = deps.store.all('SELECT id, kind, status, label FROM jobs WHERE subject_type=? AND subject_id=?', subjectType, subjectId).filter((j) => !TERMINAL.has(j.status));
|
|
for (const j of jobs) out.push({ type: 'job', id: j.id, kind: j.kind, status: j.status, label: j.label });
|
|
if (subjectType === 'site') {
|
|
for (const r of deps.store.all('SELECT id, status FROM restores WHERE site_id=? AND status NOT IN (\'done\',\'failed\',\'cancelled\',\'reverted\',\'rejected\',\'expired\')', subjectId)) out.push({ type: 'restore', id: r.id, status: r.status });
|
|
for (const d of deps.store.all('SELECT id, status FROM deployments WHERE site_id=? AND status NOT IN (\'done\',\'failed\',\'cancelled\',\'reverted\')', subjectId)) out.push({ type: 'deployment', id: d.id, status: d.status });
|
|
}
|
|
for (const r of deps.store.all('SELECT id, status FROM relocations WHERE subject_type=? AND subject_id=? AND status NOT IN (\'done\',\'failed\',\'cancelled\',\'reverted\',\'rejected\')', subjectType, subjectId)) out.push({ type: 'relocation', id: r.id, status: r.status });
|
|
const tr = deps.store.all('SELECT id, status, scope_json FROM ownership_transfers WHERE status IN (\'requested\',\'accepted\',\'on_hold\',\'reviewing\',\'approved\',\'disputed\')');
|
|
for (const t of tr) { const sc = parseJson(t.scope_json, {}); const ids = subjectType === 'site' ? sc.siteIds || [] : sc.mailServiceIds || []; if (ids.includes(subjectId)) out.push({ type: 'transfer', id: t.id, status: t.status }); }
|
|
return out;
|
|
}
|
|
/** 설정 표 */
|
|
export function settingGet(deps, key, fallback = null) { const r = deps.store.get('SELECT value_json FROM settings WHERE key=?', key); return r ? parseJson(r.value_json, fallback) : fallback; }
|
|
export function settingSet(deps, key, value, by = 'system') { deps.store.run('INSERT INTO settings(key, value_json, updated_at, updated_by) VALUES(?,?,?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json, updated_at=excluded.updated_at, updated_by=excluded.updated_by', key, toJson(value), nowIso(), by); }
|
|
/** 작업 요약(화면) */
|
|
export function jobView(deps, jobId) { return jobId ? describe(deps.jobs.get(jobId)) : null; }
|
|
/** 사람이 읽는 바이트 */
|
|
export function humanBytes(n) { n = Number(n) || 0; if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB'; if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB'; if (n >= 1024) return (n / 1024).toFixed(0) + ' KB'; return n + ' B'; }
|
|
/** 선택 의존(다른 영역 도우미)을 있을 때만 들여온다 */
|
|
export async function optional(rel) { try { const { existsSync } = await import('node:fs'); const u = new URL(rel, import.meta.url); if (!existsSync(u)) return null; return await import(u); } catch (_) { return null; } }
|
|
/** 요청 본문에서 확인 플래그(파괴적 작업 명시적 확인 §2.3) */
|
|
export function requireConfirm(body, field = 'confirm') { if (!body || body[field] !== true) throw new HttpError(400, 'confirmation_required', '대상과 영향을 확인한 뒤 명시적으로 확인해야 실행됩니다', { field }); }
|