Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
9ca4cee341
commit
c7aea8d419
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* lib/audit.mjs — 감사 기록(§24.2).
|
||||
*
|
||||
* 누가(actor)·언제·어떤 그룹과 대상(subject)에서·무엇을(action)·자동/사용자 작업인지·결과(outcome)를 남긴다.
|
||||
* 비밀값은 redact() 로 거른다. 권한·소유권·배포·해지·복원·이전·결제 승인은 immutable=1 로 두어 삭제 API 가 거부한다.
|
||||
*/
|
||||
import { newId } from './crypto.mjs';
|
||||
import { redact } from './redact.mjs';
|
||||
import { nowIso, toJson, parseJson } from './db.mjs';
|
||||
|
||||
export const MIGRATIONS = [{ id: 'lib_audit_0001', sql: `
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY, at TEXT NOT NULL, actor_type TEXT NOT NULL, actor_id TEXT, actor_label TEXT, group_id TEXT,
|
||||
subject_type TEXT, subject_id TEXT, action TEXT NOT NULL, outcome TEXT NOT NULL, automatic INTEGER NOT NULL DEFAULT 0,
|
||||
immutable INTEGER NOT NULL DEFAULT 0, detail_json TEXT, ip TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS audit_group ON audit_log(group_id, at);
|
||||
CREATE INDEX IF NOT EXISTS audit_subject ON audit_log(subject_type, subject_id, at);` }];
|
||||
|
||||
/** 삭제할 수 없는 기록으로 남기는 행동 접두어 */
|
||||
const IMMUTABLE_PREFIX = /^(permission|ownership|deploy|cancel|terminate|restore|relocate|order\.approve|order\.reject|payment|refund|member\.role|site_count|emergency|recovery)/;
|
||||
|
||||
/**
|
||||
* 기록 남기기.
|
||||
* @param {object} store lib/db
|
||||
* @param {object} e { actorType: 'user'|'operator'|'system'|'agent', actorId, actorLabel, groupId, subjectType, subjectId, action, outcome: 'ok'|'failed'|'cancelled'|'denied', automatic, detail, ip }
|
||||
*/
|
||||
export function audit(store, e) {
|
||||
const id = newId('a_');
|
||||
store.run('INSERT INTO audit_log(id, at, actor_type, actor_id, actor_label, group_id, subject_type, subject_id, action, outcome, automatic, immutable, detail_json, ip) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
id, nowIso(), e.actorType || 'system', e.actorId || null, e.actorLabel || null, e.groupId || null, e.subjectType || null, e.subjectId || null, e.action, e.outcome || 'ok',
|
||||
e.automatic ? 1 : 0, e.immutable || IMMUTABLE_PREFIX.test(e.action) ? 1 : 0, toJson(redact(e.detail || null)), e.ip || null);
|
||||
return id;
|
||||
}
|
||||
/** 조회(그룹·대상·행동·기간 필터) */
|
||||
export function listAudit(store, { groupId, subjectType, subjectId, action, since, until, limit = 200 } = {}) {
|
||||
const w = []; const p = [];
|
||||
if (groupId) { w.push('group_id=?'); p.push(groupId); }
|
||||
if (subjectType) { w.push('subject_type=?'); p.push(subjectType); }
|
||||
if (subjectId) { w.push('subject_id=?'); p.push(subjectId); }
|
||||
if (action) { w.push('action LIKE ?'); p.push(action + '%'); }
|
||||
if (since) { w.push('at>=?'); p.push(since); }
|
||||
if (until) { w.push('at<=?'); p.push(until); }
|
||||
return store.all(`SELECT * FROM audit_log ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY at DESC LIMIT ?`, ...p, Math.min(limit, 1000)).map((r) => ({ ...r, detail: parseJson(r.detail_json, null) }));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* lib/crypto.mjs — 비밀번호 해시·토큰·HMAC 공통.
|
||||
*
|
||||
* - 비밀번호: scrypt(N=2^15) + 16바이트 salt. Webmaker 와 같은 계열의 알고리즘이라 SSO 계정 동기화 때 혼동이 없다.
|
||||
* - 토큰: 32바이트 무작위 base64url. 저장은 항상 sha256 해시만(원문은 발급 응답에 한 번).
|
||||
* - HMAC: Agent 요청 서명·SSO 티켓 서명. 비교는 항상 timingSafeEqual.
|
||||
*/
|
||||
import { scryptSync, randomBytes, createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/** 비밀번호 해시. @returns {{salt:string, hash:string}} */
|
||||
export function hashPassword(password) { const salt = randomBytes(16).toString('hex'); return { salt, hash: scryptSync(String(password), salt, 64, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }).toString('hex') }; }
|
||||
/** 비밀번호 검증(상수 시간). */
|
||||
export function verifyPassword(password, salt, hash) {
|
||||
if (!salt || !hash) return false;
|
||||
const a = scryptSync(String(password), salt, 64, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }); const b = Buffer.from(hash, 'hex');
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
/** 무작위 토큰(원문). bytes 기본 32. */
|
||||
export const randomToken = (bytes = 32) => randomBytes(bytes).toString('base64url');
|
||||
/** 짧은 식별자(ULID 대용): 시각 정렬 가능한 16진 26자 — 앞 10자는 ms 시각. */
|
||||
export function newId(prefix = '') { const t = Date.now().toString(16).padStart(12, '0'); return prefix + t + randomBytes(7).toString('hex'); }
|
||||
/** 토큰 저장용 해시 */
|
||||
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
|
||||
/** HMAC-SHA256 (base64url) */
|
||||
export const hmac = (secret, data) => createHmac('sha256', secret).update(String(data)).digest('base64url');
|
||||
/** 문자열 상수 시간 비교 */
|
||||
export function safeEqual(a, b) { const x = Buffer.from(String(a || '')); const y = Buffer.from(String(b || '')); return x.length === y.length && x.length > 0 && timingSafeEqual(x, y); }
|
||||
/** 6자리 숫자 코드(이메일 확인·MFA 복구 등) */
|
||||
export const numericCode = (digits = 6) => String(parseInt(randomBytes(4).toString('hex'), 16) % 10 ** digits).padStart(digits, '0');
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* lib/db.mjs — SQLite 저장소 공통 계층 (node:sqlite, 외부 의존성 없음).
|
||||
*
|
||||
* 목적
|
||||
* HAL·포털·Agent 가 모두 같은 방식으로 DB 를 열고 마이그레이션을 적용하게 한다.
|
||||
* - WAL 모드: 읽기와 쓰기가 서로 막지 않아 화면 조회 중 작업 엔진이 기록해도 된다.
|
||||
* - foreign_keys ON: 그룹·사이트·작업 사이의 참조 무결성을 DB 가 지킨다.
|
||||
* - busy_timeout: 짧은 잠금 경쟁(두 요청이 동시에 예약을 시도)은 기다렸다가 처리한다(§13.3 동시 요청).
|
||||
*
|
||||
* 사용
|
||||
* const store = openDb('/srv/ariweb/portal/data/portal.sqlite', [{ id: '0001_init', sql: '...' }]);
|
||||
* store.get('SELECT * FROM users WHERE id=?', id) → 한 행 또는 undefined
|
||||
* store.all(...) → 행 배열
|
||||
* store.run(...) → { changes, lastInsertRowid }
|
||||
* store.tx(() => { ... }) → BEGIN IMMEDIATE … COMMIT (예외 시 ROLLBACK). 중첩되면 바깥 트랜잭션에 합류한다.
|
||||
*
|
||||
* 마이그레이션
|
||||
* `_migrations` 표에 적용한 id 를 남기고, 아직 없는 것만 순서대로 적용한다. 각 모듈(lib/session·jobs·quota·notify·audit)은
|
||||
* 자기 표의 마이그레이션을 `MIGRATIONS` 로 내보내고, 각 프로세스의 schema 파일이 그것을 모아 openDb 에 넘긴다.
|
||||
*/
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
/**
|
||||
* DB 를 열고 마이그레이션을 적용한다.
|
||||
* @param {string} file 파일 경로. ':memory:' 면 메모리 DB(테스트).
|
||||
* @param {Array<{id:string, sql:string}>} migrations 적용할 마이그레이션(순서대로)
|
||||
* @returns {{db: DatabaseSync, get: Function, all: Function, run: Function, exec: Function, tx: Function, close: Function, file: string}}
|
||||
*/
|
||||
export function openDb(file, migrations = []) {
|
||||
if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true });
|
||||
const db = new DatabaseSync(file);
|
||||
// 운영 안정성 설정. WAL 은 파일 DB 에서만 의미가 있다.
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
if (file !== ':memory:') { db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA synchronous = NORMAL'); }
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
db.exec('CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)');
|
||||
const applied = new Set(db.prepare('SELECT id FROM _migrations').all().map((r) => r.id));
|
||||
for (const m of migrations) {
|
||||
if (applied.has(m.id)) continue;
|
||||
// 한 마이그레이션은 한 트랜잭션: 중간에 실패하면 전부 되돌려 반쪽 스키마가 남지 않게 한다.
|
||||
db.exec('BEGIN');
|
||||
try { db.exec(m.sql); db.prepare('INSERT INTO _migrations(id, applied_at) VALUES(?, ?)').run(m.id, new Date().toISOString()); db.exec('COMMIT'); }
|
||||
catch (e) { db.exec('ROLLBACK'); throw new Error(`마이그레이션 실패 (${m.id}): ${e.message}`); }
|
||||
}
|
||||
let depth = 0; // 중첩 tx 깊이 — 안쪽 tx 는 BEGIN 을 다시 하지 않는다
|
||||
const store = {
|
||||
db, file,
|
||||
get: (sql, ...p) => db.prepare(sql).get(...p),
|
||||
all: (sql, ...p) => db.prepare(sql).all(...p),
|
||||
run: (sql, ...p) => db.prepare(sql).run(...p),
|
||||
exec: (sql) => db.exec(sql),
|
||||
/**
|
||||
* 즉시 잠금 트랜잭션. 쓰기 경쟁(예약·주문 중복)을 DB 수준에서 직렬화한다.
|
||||
* 동기 함수만 받는다(node:sqlite 는 동기 API 라 await 사이에 잠금을 쥐고 있으면 안 된다).
|
||||
*/
|
||||
tx(fn) {
|
||||
if (depth > 0) return fn(store);
|
||||
db.exec('BEGIN IMMEDIATE'); depth++;
|
||||
try { const r = fn(store); db.exec('COMMIT'); depth--; return r; }
|
||||
catch (e) { depth--; try { db.exec('ROLLBACK'); } catch (_) {} throw e; }
|
||||
},
|
||||
close: () => db.close(),
|
||||
};
|
||||
return store;
|
||||
}
|
||||
|
||||
/** JSON 열 읽기 도우미: 깨진 값은 fallback(화면이 깨지지 않게). */
|
||||
export function parseJson(text, fallback = null) { if (text == null || text === '') return fallback; try { return JSON.parse(text); } catch (_) { return fallback; } }
|
||||
/** JSON 열 쓰기 도우미 */
|
||||
export const toJson = (v) => (v === undefined ? null : JSON.stringify(v));
|
||||
/** 현재 시각(ISO, UTC) */
|
||||
export const nowIso = () => new Date().toISOString();
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* lib/http.mjs — 의존성 없는 작은 HTTP 앱 프레임워크(라우터·JSON·쿠키·정적 파일·오류 형식).
|
||||
*
|
||||
* 왜 직접 만드나: 형제 제품(Webmaker·AriMail)과 같이 외부 npm 의존성 없이 운영하기 위해서다. 필요한 기능은
|
||||
* 경로 매개변수(`/api/sites/:id`), JSON 본문, 쿠키, 신뢰 프록시 IP, 정적 파일, 일관된 오류 응답뿐이다.
|
||||
*
|
||||
* 핸들러: async (ctx) => 반환값
|
||||
* - 객체를 돌려주면 JSON(200). `ctx.json(obj, status)`·`ctx.html(str)`·`ctx.redirect(url)` 로 다른 응답.
|
||||
* - 예외: `ctx.error(status, code, message)` 가 만든 HttpError → { ok:false, error, message, detail? }. 그 밖의 예외는 500 + 기록.
|
||||
* 미들웨어: app.use(async (ctx) => { ... }) — 모든 라우트 전에 실행(세션 해석 등). 반환값이 있으면 그것으로 응답(차단).
|
||||
* 보안 기본값: 모든 동적 응답 `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `X-Frame-Options: SAMEORIGIN`.
|
||||
* 접속 IP: nginx 가 붙인 `X-Real-IP` 를 소켓 주소가 루프백일 때만 믿는다(클라이언트가 넣은 헤더를 믿지 않음).
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import { readFileSync, statSync, createReadStream, existsSync } from 'node:fs';
|
||||
import { join, normalize, extname } from 'node:path';
|
||||
|
||||
export class HttpError extends Error {
|
||||
/** @param {number} status @param {string} code 기계용 오류 코드 @param {string} message 사용자용 설명 @param {object} [detail] 화면이 쓸 추가 정보(비밀 없음) */
|
||||
constructor(status, code, message, detail) { super(message || code); this.status = status; this.code = code; this.detail = detail; }
|
||||
}
|
||||
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8', '.woff2': 'font/woff2' };
|
||||
const MAX_BODY = 2 * 1024 * 1024; // JSON 본문 상한(2MB). 파일 업로드는 별도 스트리밍 라우트가 맡는다.
|
||||
|
||||
/** 쿠키 헤더 파싱 */
|
||||
export function parseCookies(header) { const out = {}; for (const part of String(header || '').split(';')) { const i = part.indexOf('='); if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); } return out; }
|
||||
/** Set-Cookie 문자열 만들기 */
|
||||
export function cookie(name, value, { maxAge = 0, path = '/', secure = true, sameSite = 'Lax', httpOnly = true } = {}) {
|
||||
return `${name}=${encodeURIComponent(value)}; Path=${path}; ${httpOnly ? 'HttpOnly; ' : ''}SameSite=${sameSite}${secure ? '; Secure' : ''}${maxAge ? `; Max-Age=${maxAge}` : '; Max-Age=0'}`;
|
||||
}
|
||||
/** 경로 패턴(`/a/:id/b`)을 정규식으로 */
|
||||
function compile(pattern) {
|
||||
const keys = []; const re = new RegExp('^' + pattern.replace(/\/+$/, '').replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '(.*)').replace(/:([a-zA-Z_]+)/g, (_, k) => { keys.push(k); return '([^/]+)'; }) + '/?$');
|
||||
return { re, keys };
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 만들기.
|
||||
* @param {{ log?: Function, trustProxy?: boolean, onError?: Function }} opts
|
||||
*/
|
||||
export function createApp(opts = {}) {
|
||||
const routes = []; const middlewares = []; const statics = [];
|
||||
const log = opts.log || ((...a) => console.log(...a));
|
||||
const app = {
|
||||
/** 라우트 등록. method 는 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'*' */
|
||||
route(method, pattern, handler) { routes.push({ method: method.toUpperCase(), ...compile(pattern), pattern, handler }); return app; },
|
||||
get: (p, h) => app.route('GET', p, h), post: (p, h) => app.route('POST', p, h), put: (p, h) => app.route('PUT', p, h), patch: (p, h) => app.route('PATCH', p, h), delete: (p, h) => app.route('DELETE', p, h),
|
||||
use(fn) { middlewares.push(fn); return app; },
|
||||
/** 정적 파일: prefix 아래 요청을 dir 에서 낸다(경로 탈출 차단). */
|
||||
static(prefix, dir) { statics.push({ prefix: prefix.replace(/\/$/, ''), dir }); return app; },
|
||||
/** 요청 하나 처리(테스트에서도 직접 부른다). */
|
||||
async handle(req, res) {
|
||||
const started = Date.now();
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const ctx = makeCtx(req, res, url, opts);
|
||||
try {
|
||||
for (const s of statics) {
|
||||
if (ctx.method === 'GET' && (url.pathname === s.prefix || url.pathname.startsWith(s.prefix + '/'))) {
|
||||
const rel = normalize(url.pathname.slice(s.prefix.length) || '/').replace(/^(\.\.(\/|\\|$))+/, '');
|
||||
const file = join(s.dir, rel);
|
||||
if (!file.startsWith(s.dir)) throw new HttpError(404, 'not_found', '페이지를 찾을 수 없습니다');
|
||||
if (existsSync(file) && statSync(file).isFile()) return sendFile(res, file);
|
||||
}
|
||||
}
|
||||
for (const m of middlewares) { const r = await m(ctx); if (r !== undefined) return finish(ctx, r); if (ctx.ended) return; }
|
||||
for (const r of routes) {
|
||||
if (r.method !== '*' && r.method !== ctx.method) continue;
|
||||
const m = r.re.exec(url.pathname); if (!m) continue;
|
||||
r.keys.forEach((k, i) => { ctx.params[k] = decodeURIComponent(m[i + 1]); });
|
||||
const out = await r.handler(ctx);
|
||||
return finish(ctx, out);
|
||||
}
|
||||
throw new HttpError(404, 'not_found', '페이지를 찾을 수 없습니다');
|
||||
} catch (e) { return sendError(ctx, e, log, opts); }
|
||||
finally { if (opts.access) log(`${ctx.method} ${url.pathname} ${res.statusCode} ${Date.now() - started}ms`); }
|
||||
},
|
||||
/** 서버 시작 */
|
||||
listen(port, host = '127.0.0.1') { const server = http.createServer((req, res) => app.handle(req, res)); server.keepAliveTimeout = 65000; return new Promise((resolve) => server.listen(port, host, () => resolve(server))); },
|
||||
routes,
|
||||
};
|
||||
return app;
|
||||
}
|
||||
|
||||
/** 요청 컨텍스트 */
|
||||
function makeCtx(req, res, url, opts) {
|
||||
const sock = req.socket?.remoteAddress || '';
|
||||
const loop = sock === '127.0.0.1' || sock === '::1' || sock === '::ffff:127.0.0.1';
|
||||
const ip = (opts.trustProxy !== false && loop && req.headers['x-real-ip']) ? String(req.headers['x-real-ip']) : sock.replace(/^::ffff:/, '');
|
||||
let bodyCache;
|
||||
const ctx = {
|
||||
req, res, method: req.method.toUpperCase(), path: url.pathname, url, params: {}, query: Object.fromEntries(url.searchParams), cookies: parseCookies(req.headers.cookie), ip, headers: req.headers,
|
||||
user: null, session: null, ended: false, status: 200, outHeaders: {},
|
||||
secure: req.headers['x-forwarded-proto'] === 'https' || !!req.socket?.encrypted,
|
||||
/** JSON 본문(한 번만 읽고 캐시). 비어 있으면 {} */
|
||||
async body() {
|
||||
if (bodyCache !== undefined) return bodyCache;
|
||||
const chunks = []; let size = 0;
|
||||
for await (const c of req) { size += c.length; if (size > MAX_BODY) throw new HttpError(413, 'body_too_large', '요청 본문이 너무 큽니다'); chunks.push(c); }
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
if (!text) return (bodyCache = {});
|
||||
const ct = String(req.headers['content-type'] || '');
|
||||
if (ct.includes('application/x-www-form-urlencoded')) return (bodyCache = Object.fromEntries(new URLSearchParams(text)));
|
||||
try { return (bodyCache = JSON.parse(text)); } catch (_) { throw new HttpError(400, 'bad_json', '요청 본문이 올바른 JSON 이 아닙니다'); }
|
||||
},
|
||||
set(status, headers = {}) { ctx.status = status; Object.assign(ctx.outHeaders, headers); },
|
||||
setCookie(str) { const prev = ctx.outHeaders['Set-Cookie']; ctx.outHeaders['Set-Cookie'] = prev ? [].concat(prev, str) : str; },
|
||||
json(obj, status = 200) { ctx.status = status; return { __json: obj }; },
|
||||
html(str, status = 200) { ctx.status = status; return { __html: str }; },
|
||||
text(str, status = 200) { ctx.status = status; return { __text: str }; },
|
||||
redirect(url, status = 302) { ctx.status = status; ctx.outHeaders.Location = url; return { __text: '' }; },
|
||||
error(status, code, message, detail) { return new HttpError(status, code, message, detail); },
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
function baseHeaders(ctx) { return { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'SAMEORIGIN', ...ctx.outHeaders }; }
|
||||
function finish(ctx, out) {
|
||||
if (ctx.ended) return; ctx.ended = true;
|
||||
const h = baseHeaders(ctx);
|
||||
if (out && out.__html !== undefined) { ctx.res.writeHead(ctx.status, { 'Content-Type': 'text/html; charset=utf-8', ...h }); return ctx.res.end(out.__html); }
|
||||
if (out && out.__text !== undefined) { ctx.res.writeHead(ctx.status, { 'Content-Type': 'text/plain; charset=utf-8', ...h }); return ctx.res.end(out.__text); }
|
||||
const body = out && out.__json !== undefined ? out.__json : out;
|
||||
ctx.res.writeHead(ctx.status, { 'Content-Type': 'application/json; charset=utf-8', ...h });
|
||||
ctx.res.end(JSON.stringify(body === undefined ? { ok: true } : body));
|
||||
}
|
||||
function sendError(ctx, e, log, opts) {
|
||||
if (ctx.ended) return;
|
||||
const status = e instanceof HttpError ? e.status : (e && e.status) || 500;
|
||||
const code = e instanceof HttpError ? e.code : (e && e.code) || 'server_error';
|
||||
if (status >= 500) { log(`[http] ${ctx.method} ${ctx.path} 500: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); if (opts.onError) try { opts.onError(e, ctx); } catch (_) {} }
|
||||
ctx.ended = true;
|
||||
ctx.res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...baseHeaders(ctx) });
|
||||
ctx.res.end(JSON.stringify({ ok: false, error: code, message: status >= 500 ? '일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요' : (e.message || code), ...(e.detail ? { detail: e.detail } : {}) }));
|
||||
}
|
||||
function sendFile(res, file) {
|
||||
const type = MIME[extname(file).toLowerCase()] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
|
||||
createReadStream(file).pipe(res);
|
||||
}
|
||||
/** 간단한 HTML 이스케이프 */
|
||||
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
/** 테스트용: 앱에 가짜 요청을 보내고 { status, headers, json, text } 를 돌려준다(소켓 없이). */
|
||||
export async function invoke(app, method, path, { body, headers = {}, cookies = {}, ip = '127.0.0.1' } = {}) {
|
||||
const { PassThrough } = await import('node:stream');
|
||||
const req = new PassThrough(); req.method = method; req.url = path;
|
||||
req.headers = { 'content-type': 'application/json', ...headers };
|
||||
if (Object.keys(cookies).length) req.headers.cookie = Object.entries(cookies).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ');
|
||||
req.socket = { remoteAddress: ip };
|
||||
if (body !== undefined) req.end(typeof body === 'string' ? body : JSON.stringify(body)); else req.end();
|
||||
const out = { status: 0, headers: {}, chunks: [] };
|
||||
const res = { writeHead(s, h) { out.status = s; out.headers = h || {}; }, end(d) { if (d) out.chunks.push(Buffer.from(d)); out.done = true; resolve(); }, write(d) { out.chunks.push(Buffer.from(d)); }, statusCode: 200, on() {}, once() {}, emit() {} };
|
||||
let resolve; const p = new Promise((r) => { resolve = r; });
|
||||
await app.handle(req, res); await p;
|
||||
const text = Buffer.concat(out.chunks).toString('utf8');
|
||||
let json = null; try { json = JSON.parse(text); } catch (_) {}
|
||||
const setCookie = [].concat(out.headers['Set-Cookie'] || []);
|
||||
return { status: out.status, headers: out.headers, text, json, cookies: Object.fromEntries(setCookie.map((c) => { const [kv] = c.split(';'); const i = kv.indexOf('='); return [kv.slice(0, i), decodeURIComponent(kv.slice(i + 1))]; })) };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* lib/i18n.mjs — 화면 문구 사전. 문구를 코드에 박지 않고 lang/ko.json 에서 읽는다(§9 규약).
|
||||
* t('auth.login.failed') → 문자열. {name} 자리표시자는 vars 로 치환. 없는 키는 키 그대로 돌려줘 누락이 화면에 드러난다.
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
let dict = null; let loadedAt = 0;
|
||||
/** lang/ko/*.json 을 모두 읽어 깊게 합친다(영역마다 파일을 나눠 충돌 없이 관리). 운영 중 수정은 5초 안에 반영. */
|
||||
function load() {
|
||||
if (dict && Date.now() - loadedAt < 5000) return dict;
|
||||
const dir = join(ROOT, 'lang', 'ko'); const merged = {};
|
||||
const deepMerge = (a, b) => { for (const [k, v] of Object.entries(b)) { if (v && typeof v === 'object' && !Array.isArray(v) && a[k] && typeof a[k] === 'object') deepMerge(a[k], v); else a[k] = v; } return a; };
|
||||
try { for (const f of readdirSync(dir).filter((x) => x.endsWith('.json')).sort()) { try { deepMerge(merged, JSON.parse(readFileSync(join(dir, f), 'utf8'))); } catch (e) { console.error(`[i18n] ${f} 읽기 실패: ${e.message}`); } } } catch (_) {}
|
||||
dict = merged; loadedAt = Date.now(); return dict;
|
||||
}
|
||||
/** 점(.)으로 중첩 키를 찾는다 */
|
||||
function lookup(obj, key) { return key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj); }
|
||||
export function t(key, vars = {}) {
|
||||
const v = lookup(load(), key);
|
||||
const s = typeof v === 'string' ? v : key;
|
||||
return s.replace(/\{(\w+)\}/g, (_, k) => (vars[k] !== undefined ? String(vars[k]) : `{${k}}`));
|
||||
}
|
||||
/** 사전 전체(화면 JS 에 내려보낼 때) */
|
||||
export function all() { return load(); }
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* lib/jobs.mjs — 내구성 작업 엔진(§2.2·§29·§36).
|
||||
*
|
||||
* 생각의 틀
|
||||
* 배포·복원·이전·백업·주문 처리처럼 "오래 걸리고 중간에 프로세스가 죽을 수 있는" 일은 모두 작업(job)이다.
|
||||
* 작업은 DB 행이며 단계(step)마다 결과를 저장한다. 프로세스가 재시작되면 끝난 단계는 건너뛰고 다음 단계부터 이어서 실행한다.
|
||||
* 완료되지 않은 작업을 실패로 숨기지 않고, 재시도 횟수·다음 시각·외부 대기 사유·사용자 확인 필요를 상태로 드러낸다.
|
||||
*
|
||||
* 핵심 규약
|
||||
* - register(kind, def): def = { title, steps:[{id,title}], run(job, ctx), revert?(job, ctx), maxAttempts?, retryable?(err) }
|
||||
* - run() 은 **재진입 가능**해야 한다: 같은 작업으로 여러 번 불릴 수 있으므로 부수 효과가 있는 일은 ctx.step(id, fn) 안에서 한다.
|
||||
* ctx.step 은 이미 done 인 단계면 저장된 결과를 돌려주고 fn 을 실행하지 않는다.
|
||||
* - 같은 idempotencyKey 로 enqueue 하면 새 작업을 만들지 않고 기존 작업을 돌려준다(버튼 연타·새로 고침 §36).
|
||||
* - 일시 오류(err.retryable=true 또는 def.retryable(err)) 는 지수 백오프로 재시도하고 attempt·next_run_at 을 보인다.
|
||||
* - ctx.waitExternal(reason, {resumeAt}) → AWAITING_EXTERNAL. ctx.needsUser(message, actions) → NEEDS_USER. resume(id) 로 다시 QUEUED.
|
||||
* - 되돌리기: revert(id) 는 def.revert 를 실행하며 REVERTING → REVERTED(실패하면 FAILED + error). 되돌리기 전 데이터 영향은 호출자가 안내한다.
|
||||
* - 부모/자식: parentId 로 대상별 작업(여러 서버 모듈 갱신 §29)을 묶고 childrenSummary 로 대상별 성공·실패·대기를 보인다. 일부 실패를 전체 성공으로 보이지 않는다.
|
||||
*/
|
||||
import { newId } from './crypto.mjs';
|
||||
import { redact, safeError } from './redact.mjs';
|
||||
import { nowIso, toJson, parseJson } from './db.mjs';
|
||||
import { JOB_STATUS as S, TERMINAL, CANCELLABLE } from './status.mjs';
|
||||
|
||||
export const MIGRATIONS = [{ id: 'lib_jobs_0001', sql: `
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY, kind TEXT NOT NULL, status TEXT NOT NULL, label TEXT, subject_json TEXT, steps_json TEXT, attempt INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 1,
|
||||
next_run_at TEXT, locked_by TEXT, locked_at TEXT, result_json TEXT, error TEXT, error_code TEXT, wait_reason TEXT, user_message TEXT, actions_json TEXT, eta_at TEXT, progress INTEGER NOT NULL DEFAULT 0,
|
||||
idempotency_key TEXT UNIQUE, created_by TEXT, actor_type TEXT, group_id TEXT, subject_type TEXT, subject_id TEXT, parent_id TEXT, external_ref TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, started_at TEXT, ended_at TEXT, log_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS jobs_status ON jobs(status, next_run_at);
|
||||
CREATE INDEX IF NOT EXISTS jobs_group ON jobs(group_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS jobs_subject ON jobs(subject_type, subject_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS jobs_parent ON jobs(parent_id);` }];
|
||||
|
||||
/** run() 안에서 흐름을 바꾸는 신호(예외로 전달) */
|
||||
class Signal extends Error { constructor(type, payload) { super(type); this.signal = type; this.payload = payload || {}; } }
|
||||
export class JobError extends Error { constructor(code, message, { retryable = false, detail } = {}) { super(message || code); this.code = code; this.retryable = retryable; this.detail = detail; } }
|
||||
|
||||
const BACKOFF_BASE_MS = 30 * 1000; const BACKOFF_MAX_MS = 60 * 60 * 1000; const STALE_LOCK_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 작업 엔진 만들기.
|
||||
* @param {object} store lib/db
|
||||
* @param {{workerId?:string, pollMs?:number, log?:Function}} opts
|
||||
*/
|
||||
export function createJobEngine(store, opts = {}) {
|
||||
const kinds = new Map(); const listeners = new Set();
|
||||
const workerId = opts.workerId || `${process.pid}@${(await0() || 'host')}`; const log = opts.log || (() => {});
|
||||
let timer = null; let running = 0; let stopped = true; const concurrency = opts.concurrency || 4;
|
||||
|
||||
function row2job(r) { if (!r) return null; return { ...r, subject: parseJson(r.subject_json, {}), steps: parseJson(r.steps_json, []), result: parseJson(r.result_json, null), actions: parseJson(r.actions_json, null), log: parseJson(r.log_json, []) }; }
|
||||
function emit(job) { for (const fn of listeners) { try { fn(job); } catch (e) { log('[jobs] listener error', e.message); } } }
|
||||
function save(id, patch) {
|
||||
const sets = []; const vals = [];
|
||||
for (const [k, v] of Object.entries(patch)) { sets.push(`${k}=?`); vals.push(v && typeof v === 'object' ? toJson(v) : v); }
|
||||
sets.push('updated_at=?'); vals.push(nowIso());
|
||||
store.run(`UPDATE jobs SET ${sets.join(', ')} WHERE id=?`, ...vals, id);
|
||||
const j = engine.get(id); emit(j); return j;
|
||||
}
|
||||
const engine = {
|
||||
workerId,
|
||||
register(kind, def) { kinds.set(kind, { maxAttempts: 1, steps: [], ...def }); return engine; },
|
||||
kinds: () => [...kinds.keys()],
|
||||
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
|
||||
/**
|
||||
* 작업 넣기. idempotencyKey 가 같은 작업이 있으면 그것을 돌려준다(created=false).
|
||||
* @returns {{job:object, created:boolean}}
|
||||
*/
|
||||
enqueue({ kind, subject = {}, idempotencyKey = null, createdBy = null, actorType = 'user', groupId = null, subjectType = null, subjectId = null, label = null, parentId = null, runAt = null, maxAttempts = null, initialStatus = null }) {
|
||||
const def = kinds.get(kind); if (!def) throw new JobError('unknown_job_kind', `알 수 없는 작업 종류: ${kind}`);
|
||||
return store.tx(() => {
|
||||
if (idempotencyKey) { const ex = store.get('SELECT * FROM jobs WHERE idempotency_key=?', idempotencyKey); if (ex) return { job: row2job(ex), created: false }; }
|
||||
const id = newId('j_'); const now = nowIso();
|
||||
const steps = (def.steps || []).map((s) => ({ id: s.id, title: s.title, state: 'pending' }));
|
||||
store.run('INSERT INTO jobs(id, kind, status, label, subject_json, steps_json, attempt, max_attempts, next_run_at, idempotency_key, created_by, actor_type, group_id, subject_type, subject_id, parent_id, created_at, updated_at, log_json) VALUES(?,?,?,?,?,?,0,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
id, kind, initialStatus || S.QUEUED, label || def.title || kind, toJson(subject), toJson(steps), maxAttempts || def.maxAttempts || 1, runAt || now, idempotencyKey, createdBy, actorType, groupId, subjectType, subjectId, parentId, now, now, '[]');
|
||||
const job = engine.get(id); emit(job); return { job, created: true };
|
||||
});
|
||||
},
|
||||
get: (id) => row2job(store.get('SELECT * FROM jobs WHERE id=?', id)),
|
||||
list({ groupId, status, kind, subjectType, subjectId, parentId, limit = 100 } = {}) {
|
||||
const w = []; const p = [];
|
||||
if (groupId) { w.push('group_id=?'); p.push(groupId); } if (status) { w.push(Array.isArray(status) ? `status IN (${status.map(() => '?').join(',')})` : 'status=?'); p.push(...[].concat(status)); }
|
||||
if (kind) { w.push('kind=?'); p.push(kind); } if (subjectType) { w.push('subject_type=?'); p.push(subjectType); } if (subjectId) { w.push('subject_id=?'); p.push(subjectId); } if (parentId) { w.push('parent_id=?'); p.push(parentId); }
|
||||
return store.all(`SELECT * FROM jobs ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY created_at DESC LIMIT ?`, ...p, Math.min(limit, 1000)).map(row2job);
|
||||
},
|
||||
/** 자식 작업 요약(대상별 성공·실패·대기). 일부 실패는 'partial' */
|
||||
childrenSummary(parentId) {
|
||||
const kids = engine.list({ parentId, limit: 1000 }); const by = {}; for (const k of kids) by[k.status] = (by[k.status] || 0) + 1;
|
||||
const failed = kids.filter((k) => ['failed', 'needs_user'].includes(k.status)).length; const done = kids.filter((k) => k.status === 'done').length;
|
||||
return { total: kids.length, byStatus: by, overall: kids.length === 0 ? 'none' : done === kids.length ? 'done' : failed && done ? 'partial' : failed === kids.length ? 'failed' : 'running', children: kids.map((k) => ({ id: k.id, label: k.label, status: k.status, subject: k.subject, error: k.error, attempt: k.attempt, next_run_at: k.next_run_at })) };
|
||||
},
|
||||
/** 외부 대기·사용자 확인·일시 중지 상태의 작업을 다시 큐에 넣는다 */
|
||||
resume(id, by = null) { const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다'); if (![S.AWAITING_EXTERNAL, S.NEEDS_USER, S.PAUSED, S.AWAITING_APPROVAL, S.AWAITING_PAYMENT, S.NOT_READY, S.NEEDS_SETUP].includes(j.status)) throw new JobError('job_not_resumable', `지금 상태(${j.status})에서는 이어서 할 수 없습니다`); return save(id, { status: S.QUEUED, next_run_at: nowIso(), wait_reason: null, user_message: null, actions_json: null, log_json: appendLog(j, `재개 (${by || 'system'})`) }); },
|
||||
/** 실패한 작업 재시도(사용자·운영자 명시) */
|
||||
retry(id, by = null) { const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다'); if (![S.FAILED, S.NEEDS_USER].includes(j.status)) throw new JobError('job_not_retryable', `지금 상태(${j.status})에서는 재시도할 수 없습니다`); return save(id, { status: S.QUEUED, next_run_at: nowIso(), error: null, error_code: null, max_attempts: j.attempt + 1, log_json: appendLog(j, `재시도 요청 (${by || 'system'})`) }); },
|
||||
pause(id, by = null) { const j = engine.get(id); if (!j || ![S.QUEUED, S.AWAITING_EXTERNAL].includes(j.status)) throw new JobError('job_not_pausable', '지금 상태에서는 일시 중지할 수 없습니다'); return save(id, { status: S.PAUSED, log_json: appendLog(j, `일시 중지 (${by || 'system'})`) }); },
|
||||
/** 취소: 대기 상태면 즉시, 실행 중이면 취소 요청 표시(ctx.checkCancel 에서 멈춤) */
|
||||
cancel(id, by = null) {
|
||||
const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다');
|
||||
if (CANCELLABLE.has(j.status)) return save(id, { status: S.CANCELLED, ended_at: nowIso(), log_json: appendLog(j, `취소 (${by || 'system'})`) });
|
||||
if (j.status === S.RUNNING) return save(id, { cancel_requested: 1, log_json: appendLog(j, `취소 요청 (${by || 'system'})`) });
|
||||
throw new JobError('job_not_cancellable', `지금 상태(${j.status})에서는 취소할 수 없습니다`);
|
||||
},
|
||||
/** 되돌리기 */
|
||||
async revert(id, by = null) {
|
||||
const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다');
|
||||
const def = kinds.get(j.kind); if (!def || !def.revert) throw new JobError('job_not_revertible', '이 작업은 되돌리기를 지원하지 않습니다');
|
||||
if (![S.DONE, S.FAILED, S.NEEDS_USER].includes(j.status)) throw new JobError('job_not_revertible', `지금 상태(${j.status})에서는 되돌릴 수 없습니다`);
|
||||
save(id, { status: S.REVERTING, log_json: appendLog(j, `되돌리기 시작 (${by || 'system'})`) });
|
||||
const ctx = makeCtx(engine.get(id), { reverting: true });
|
||||
try { await def.revert(engine.get(id), ctx); return save(id, { status: S.REVERTED, ended_at: nowIso(), steps_json: ctx.steps(), log_json: ctx.logs() }); }
|
||||
catch (e) { return save(id, { status: S.FAILED, error: safeError(e), error_code: e.code || 'revert_failed', steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso() }); }
|
||||
},
|
||||
/** 외부(HAL 등)에서 진행 상태를 밀어 넣을 때 */
|
||||
update(id, patch) { return save(id, patch); },
|
||||
/** 프로세스 시작 시: 이전 실행에서 RUNNING 으로 남은 작업(잠금 소유자 불문, 단일 프로세스 운영)을 QUEUED 로 되돌려 이어서 실행한다 */
|
||||
recover() {
|
||||
const stale = store.all('SELECT * FROM jobs WHERE status=?', S.RUNNING);
|
||||
for (const r of stale) { const j = row2job(r); save(j.id, { status: S.QUEUED, locked_by: null, locked_at: null, next_run_at: nowIso(), log_json: appendLog(j, '프로세스 재시작 — 끝난 단계 다음부터 이어서 실행') }); }
|
||||
return stale.length;
|
||||
},
|
||||
/** 실행 가능한 작업 하나를 집어 실행(테스트에서도 직접 부름). 돌아온 값: 실행한 작업 또는 null */
|
||||
async tick() {
|
||||
if (running >= concurrency) return null;
|
||||
const now = nowIso();
|
||||
const cand = store.get('SELECT id FROM jobs WHERE status=? AND (next_run_at IS NULL OR next_run_at<=?) ORDER BY next_run_at ASC, created_at ASC LIMIT 1', S.QUEUED, now);
|
||||
if (!cand) return null;
|
||||
const got = store.run('UPDATE jobs SET status=?, locked_by=?, locked_at=?, started_at=COALESCE(started_at, ?), attempt=attempt+1, updated_at=? WHERE id=? AND status=?', S.RUNNING, workerId, now, now, now, cand.id, S.QUEUED).changes;
|
||||
if (!got) return null;
|
||||
running++;
|
||||
try { return await execute(cand.id); } finally { running--; }
|
||||
},
|
||||
/** 모든 큐 작업을 비울 때까지 tick(테스트용) */
|
||||
async drain(max = 100) { let n = 0; while (n < max) { const r = await engine.tick(); if (!r) break; n++; } return n; },
|
||||
start() { stopped = false; engine.recover(); const loop = async () => { if (stopped) return; try { let n = 0; while (n < concurrency && (await engine.tick())) n++; } catch (e) { log('[jobs] tick error', safeError(e)); } timer = setTimeout(loop, opts.pollMs || 1000); }; loop(); return engine; },
|
||||
stop() { stopped = true; if (timer) clearTimeout(timer); },
|
||||
};
|
||||
function appendLog(job, line) { const l = (job.log || []).slice(-200); l.push({ at: nowIso(), line: String(line).slice(0, 500) }); return l; }
|
||||
function makeCtx(job, { reverting = false } = {}) {
|
||||
const steps = (job.steps || []).map((s) => ({ ...s })); const logs = (job.log || []).slice(-200);
|
||||
const ctx = {
|
||||
job, reverting, workerId,
|
||||
/** 단계 실행. 이미 done 이면 저장된 결과를 돌려준다. 결과는 JSON 직렬화 가능해야 한다. */
|
||||
async step(id, fn, title) {
|
||||
let s = steps.find((x) => x.id === id); if (!s) { s = { id, title: title || id, state: 'pending' }; steps.push(s); }
|
||||
if (s.state === 'done' && !reverting) return s.result;
|
||||
s.state = 'running'; s.startedAt = nowIso(); delete s.error; ctx.flush();
|
||||
try { const result = await fn(); s.state = 'done'; s.endedAt = nowIso(); s.result = result === undefined ? null : redact(result); ctx.flush(); return result; }
|
||||
catch (e) { if (e instanceof Signal) { s.state = e.signal === 'wait' ? 'waiting' : e.signal === 'needs_user' ? 'needs_user' : 'pending'; s.detail = e.payload.reason || e.payload.message || ''; ctx.flush(); throw e; } s.state = 'failed'; s.endedAt = nowIso(); s.error = safeError(e); ctx.flush(); throw e; }
|
||||
},
|
||||
log(line) { logs.push({ at: nowIso(), line: String(line).slice(0, 500) }); ctx.flush(); },
|
||||
progress(pct) { store.run('UPDATE jobs SET progress=?, updated_at=? WHERE id=?', Math.max(0, Math.min(100, Math.round(pct))), nowIso(), job.id); },
|
||||
eta(at) { store.run('UPDATE jobs SET eta_at=?, updated_at=? WHERE id=?', at || null, nowIso(), job.id); },
|
||||
/** 외부 작업(사용자 DNS 변경·운영자 승인 등)을 기다린다. run() 은 여기서 끝나고 resume() 뒤 다시 불린다. */
|
||||
waitExternal(reason, { resumeAt = null } = {}) { throw new Signal('wait', { reason, resumeAt }); },
|
||||
/** 사용자 확인이 필요해 멈춘다. actions: [{id,label,description}] */
|
||||
needsUser(message, actions = null) { throw new Signal('needs_user', { message, actions }); },
|
||||
/** 일시 오류로 나중에 다시(지수 백오프 대신 지정 시각) */
|
||||
retryLater(ms, reason) { throw new Signal('retry', { ms, reason }); },
|
||||
/** 취소 요청이 들어왔으면 멈춘다 */
|
||||
checkCancel() { const r = store.get('SELECT cancel_requested FROM jobs WHERE id=?', job.id); if (r && r.cancel_requested) throw new Signal('cancel', {}); },
|
||||
steps: () => steps, logs: () => logs,
|
||||
flush() { store.run('UPDATE jobs SET steps_json=?, log_json=?, updated_at=? WHERE id=?', toJson(steps), toJson(logs), nowIso(), job.id); },
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
async function execute(id) {
|
||||
const job = engine.get(id); const def = kinds.get(job.kind);
|
||||
if (!def) return save(id, { status: S.FAILED, error: `알 수 없는 작업 종류: ${job.kind}`, error_code: 'unknown_job_kind', ended_at: nowIso() });
|
||||
const ctx = makeCtx(job);
|
||||
try {
|
||||
const result = await def.run(job, ctx);
|
||||
return save(id, { status: S.DONE, result_json: redact(result === undefined ? null : result), progress: 100, steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso(), locked_by: null, error: null, error_code: null });
|
||||
} catch (e) {
|
||||
if (e instanceof Signal) {
|
||||
// 단계 밖에서 대기·확인 신호가 나면 다음 미완료 단계에 그 상태를 표시해 화면이 "현재 단계" 를 보일 수 있게 한다
|
||||
if (['wait', 'needs_user'].includes(e.signal)) { const st = ctx.steps(); if (!st.some((x) => ['waiting', 'needs_user'].includes(x.state))) { const nx = st.find((x) => x.state === 'pending'); if (nx) { nx.state = e.signal === 'wait' ? 'waiting' : 'needs_user'; nx.detail = e.payload.reason || e.payload.message || ''; } } }
|
||||
if (e.signal === 'wait') return save(id, { status: S.AWAITING_EXTERNAL, wait_reason: e.payload.reason || null, eta_at: e.payload.resumeAt || null, next_run_at: e.payload.resumeAt || null, steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null });
|
||||
if (e.signal === 'needs_user') return save(id, { status: S.NEEDS_USER, user_message: e.payload.message || null, actions_json: e.payload.actions || null, steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null });
|
||||
if (e.signal === 'cancel') return save(id, { status: S.CANCELLED, ended_at: nowIso(), steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null, cancel_requested: 0 });
|
||||
if (e.signal === 'retry') { const at = new Date(Date.now() + (e.payload.ms || BACKOFF_BASE_MS)).toISOString(); return save(id, { status: S.QUEUED, next_run_at: at, wait_reason: e.payload.reason || null, max_attempts: Math.max(job.max_attempts, job.attempt + 1), steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null }); }
|
||||
}
|
||||
const retryable = !!(e && e.retryable) || (def.retryable ? !!def.retryable(e) : false);
|
||||
const fresh = engine.get(id);
|
||||
if (retryable && fresh.attempt < fresh.max_attempts) {
|
||||
const backoff = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (fresh.attempt - 1));
|
||||
return save(id, { status: S.QUEUED, next_run_at: new Date(Date.now() + backoff).toISOString(), error: safeError(e), error_code: e.code || 'transient', steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null });
|
||||
}
|
||||
log(`[jobs] ${job.kind} ${id} 실패: ${safeError(e)}`);
|
||||
return save(id, { status: S.FAILED, error: safeError(e), error_code: e.code || 'failed', steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso(), locked_by: null });
|
||||
}
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
function await0() { try { return (process.env.HOSTNAME || '').slice(0, 20); } catch (_) { return ''; } }
|
||||
/** 화면용 요약: 현재 단계·완료 단계·다음 단계·재시도 정보(§2.2) */
|
||||
export function describe(job) {
|
||||
if (!job) return null;
|
||||
const steps = job.steps || []; const doneSteps = steps.filter((s) => s.state === 'done'); const current = steps.find((s) => ['running', 'waiting', 'needs_user', 'failed'].includes(s.state)) || steps.find((s) => s.state === 'pending') || null;
|
||||
const idx = current ? steps.indexOf(current) : steps.length; const next = steps[idx + 1] || null;
|
||||
return {
|
||||
id: job.id, kind: job.kind, label: job.label, status: job.status, progress: job.progress, attempt: job.attempt, maxAttempts: job.max_attempts, nextRunAt: job.next_run_at, etaAt: job.eta_at || null,
|
||||
etaUnknownReason: job.eta_at ? null : '외부 반영 시간에 따라 달라져 산정할 수 없습니다', updatedAt: job.updated_at, startedAt: job.started_at, endedAt: job.ended_at,
|
||||
currentStep: current ? { id: current.id, title: current.title, state: current.state, detail: current.detail || current.error || null } : null,
|
||||
completedSteps: doneSteps.map((s) => ({ id: s.id, title: s.title })), nextStep: next ? { id: next.id, title: next.title } : null, steps,
|
||||
waitReason: job.wait_reason, userMessage: job.user_message, actions: job.actions, error: job.error, errorCode: job.error_code,
|
||||
canCancel: CANCELLABLE.has(job.status) || job.status === 'running', canRetry: ['failed', 'needs_user'].includes(job.status), canResume: ['awaiting_external', 'needs_user', 'paused'].includes(job.status), terminal: TERMINAL.has(job.status),
|
||||
stale: job.status === 'running' && Date.now() - Date.parse(job.updated_at) > 10 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* lib/kst.mjs — 한국 표준시(KST, UTC+9) 월 경계 계산.
|
||||
*
|
||||
* 월간 사용량(웹 아웃바운드·메일 통수)은 "KST 월" 기준으로 집계·초기화한다(§13.3·§13.4·§17.2).
|
||||
* UTC 로 저장된 시각을 KST 월로 나누려면 9시간을 더해 날짜를 구하고, 월 시작을 다시 UTC 로 돌린다.
|
||||
* 테스트가 고정 시각으로 검증할 수 있게 모든 함수는 `at` 을 인자로 받는다(AriMail usage.test 의 월 경계 오류 재발 방지).
|
||||
*/
|
||||
const KST_OFFSET_MS = 9 * 3600 * 1000;
|
||||
|
||||
/** at 이 속한 KST 월의 시작(UTC ISO). 예: 2026-10-31T16:00:00Z(=11-01 01:00 KST) → 2026-10-31T15:00:00.000Z */
|
||||
export function monthStart(at = new Date()) {
|
||||
const k = new Date(new Date(at).getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), 1) - KST_OFFSET_MS).toISOString();
|
||||
}
|
||||
/** 다음 KST 월 시작(UTC ISO) = 월간 사용량 초기화 시각 */
|
||||
export function nextMonthStart(at = new Date()) {
|
||||
const k = new Date(new Date(at).getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth() + 1, 1) - KST_OFFSET_MS).toISOString();
|
||||
}
|
||||
/** KST 기준 YYYY-MM-DD */
|
||||
export function kstDate(at = new Date()) { return new Date(new Date(at).getTime() + KST_OFFSET_MS).toISOString().slice(0, 10); }
|
||||
/** KST 기준 YYYY-MM */
|
||||
export function kstMonth(at = new Date()) { return kstDate(at).slice(0, 7); }
|
||||
/** 화면 표시용 "YYYY-MM-DD HH:mm (KST)" */
|
||||
export function fmtKst(at) { if (!at) return ''; const k = new Date(new Date(at).getTime() + KST_OFFSET_MS); return k.toISOString().slice(0, 16).replace('T', ' ') + ' KST'; }
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* lib/notify.mjs — 알림 센터(§24.1).
|
||||
*
|
||||
* 분류(category): billing | site | domain | mail | usage | backup | relocation | security | incident | membership | ops
|
||||
* 중요도(severity): info | warning | critical. mandatory=1 은 필수 알림(서비스 중단·보안·결제·데이터 삭제 예정) → 외부 이메일도 보낸다.
|
||||
* 같은 원인의 반복 알림은 incident_key 로 하나의 사건으로 묶어 count·last_at·title/body 를 갱신한다(§24.1).
|
||||
* link 는 사용자가 조치할 정확한 화면(§2.3). group_id 가 없으면 운영자 알림.
|
||||
* 외부 이메일은 setMailer(fn) 로 끼운 발송기(포털이 AriMail/Resend 로 보냄)가 담당하며, 실패해도 알림 자체는 남는다.
|
||||
*/
|
||||
import { newId } from './crypto.mjs';
|
||||
import { redact } from './redact.mjs';
|
||||
import { nowIso, toJson, parseJson } from './db.mjs';
|
||||
|
||||
export const MIGRATIONS = [{ id: 'lib_notify_0001', sql: `
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id TEXT PRIMARY KEY, group_id TEXT, user_id TEXT, category TEXT NOT NULL, severity TEXT NOT NULL DEFAULT 'info', mandatory INTEGER NOT NULL DEFAULT 0,
|
||||
incident_key TEXT, title TEXT NOT NULL, body TEXT, link TEXT, subject_type TEXT, subject_id TEXT, action_required TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'open', count INTEGER NOT NULL DEFAULT 1, first_at TEXT NOT NULL, last_at TEXT NOT NULL, read_at TEXT, resolved_at TEXT, email_sent_at TEXT, email_error TEXT, detail_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS notif_group ON notifications(group_id, state, last_at);
|
||||
CREATE INDEX IF NOT EXISTS notif_incident ON notifications(incident_key, state);` }];
|
||||
|
||||
let mailer = null;
|
||||
/** 외부 이메일 발송기 등록: async ({groupId, userId, title, body, link}) => void */
|
||||
export function setMailer(fn) { mailer = fn; }
|
||||
|
||||
/**
|
||||
* 알림 만들기(또는 같은 사건 갱신).
|
||||
* @returns {{id:string, merged:boolean}}
|
||||
*/
|
||||
export function notify(store, n) {
|
||||
const now = nowIso(); const detail = toJson(redact(n.detail || null));
|
||||
if (n.incidentKey) {
|
||||
const open = store.get('SELECT id, count FROM notifications WHERE incident_key=? AND state=\'open\'', n.incidentKey);
|
||||
if (open) { store.run('UPDATE notifications SET count=count+1, last_at=?, title=?, body=?, link=COALESCE(?, link), severity=?, action_required=?, detail_json=?, read_at=NULL WHERE id=?', now, n.title, n.body || null, n.link || null, n.severity || 'info', n.actionRequired || null, detail, open.id); return { id: open.id, merged: true }; }
|
||||
}
|
||||
const id = newId('n_');
|
||||
store.run('INSERT INTO notifications(id, group_id, user_id, category, severity, mandatory, incident_key, title, body, link, subject_type, subject_id, action_required, state, count, first_at, last_at, detail_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,\'open\',1,?,?,?)',
|
||||
id, n.groupId || null, n.userId || null, n.category, n.severity || 'info', n.mandatory ? 1 : 0, n.incidentKey || null, n.title, n.body || null, n.link || null, n.subjectType || null, n.subjectId || null, n.actionRequired || null, now, now, detail);
|
||||
// 필수 알림·critical 은 외부 이메일(§24.1). 발송 실패는 email_error 에 남긴다(알림은 유지).
|
||||
if ((n.mandatory || n.severity === 'critical' || n.email) && mailer) {
|
||||
Promise.resolve().then(() => mailer({ groupId: n.groupId, userId: n.userId, title: n.title, body: n.body, link: n.link }))
|
||||
.then(() => store.run('UPDATE notifications SET email_sent_at=? WHERE id=?', nowIso(), id))
|
||||
.catch((e) => { try { store.run('UPDATE notifications SET email_error=? WHERE id=?', String(e.message || e).slice(0, 300), id); } catch (_) {} });
|
||||
}
|
||||
return { id, merged: false };
|
||||
}
|
||||
/** 사건 종료(같은 incident_key 의 열린 알림을 resolved 로) */
|
||||
export function resolveIncident(store, incidentKey, note = '') { return store.run('UPDATE notifications SET state=\'resolved\', resolved_at=?, body=CASE WHEN ?<>\'\' THEN ? ELSE body END WHERE incident_key=? AND state=\'open\'', nowIso(), note, note, incidentKey).changes; }
|
||||
/** 목록(그룹 기준, 최신순). unreadOnly / category 필터 */
|
||||
export function listNotifications(store, { groupId, userId, category, unreadOnly = false, limit = 100 } = {}) {
|
||||
const w = []; const p = [];
|
||||
if (groupId !== undefined) { w.push(groupId === null ? 'group_id IS NULL' : '(group_id=? OR (group_id IS NULL AND user_id=?))'); if (groupId !== null) p.push(groupId, userId || ''); }
|
||||
if (category) { w.push('category=?'); p.push(category); }
|
||||
if (unreadOnly) w.push('read_at IS NULL');
|
||||
return store.all(`SELECT * FROM notifications ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY last_at DESC LIMIT ?`, ...p, Math.min(limit, 500)).map((r) => ({ ...r, detail: parseJson(r.detail_json, null) }));
|
||||
}
|
||||
export function markRead(store, id, groupId) { return store.run('UPDATE notifications SET read_at=? WHERE id=? AND (group_id=? OR group_id IS NULL) AND read_at IS NULL', nowIso(), id, groupId || null).changes; }
|
||||
export function unreadCount(store, groupId) { return store.get('SELECT COUNT(*) AS n FROM notifications WHERE group_id=? AND read_at IS NULL AND state=\'open\'', groupId)?.n || 0; }
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* lib/quota.mjs — 상품 권리(entitlement)·서비스별 배정(allocation)·진행 중 예약(reservation) (§13·§14·§6.4).
|
||||
*
|
||||
* 세 층
|
||||
* entitlement 그룹이 구매해 활성화된 총량(상품정책 버전 참조). 결제·승인 전에는 만들지 않는다(§6.2).
|
||||
* allocation 사이트·메일 서비스에 배정된 양(사용 중). 실제 사용량(usage)보다 작게 줄일 수 없다.
|
||||
* reservation 진행 중 작업(최초 공개 배포·메일 생성 등)이 잡아 둔 양. 성공하면 commit → allocation, 실패·취소·되돌리기면 release → 반환.
|
||||
* 불변식: Σ(allocation) + Σ(reserved) ≤ Σ(active entitlement). reserve/commit/setAllocation 은 BEGIN IMMEDIATE 트랜잭션 안에서 검사해
|
||||
* 마지막 남은 단위를 두 작업이 동시에 요청하면 하나만 성공한다(§13.3, 검증 27).
|
||||
* 자원 종류(RESOURCES): sites(개) web_disk(바이트) web_out(바이트/월) mail_storage(바이트) mail_msgs(통/월) mailboxes(개) mail_services(개) source(개) backup(개)
|
||||
*/
|
||||
import { newId } from './crypto.mjs';
|
||||
import { nowIso } from './db.mjs';
|
||||
|
||||
export const RESOURCES = Object.freeze({
|
||||
sites: { unit: 'count', label: '운영 사이트 수' }, web_disk: { unit: 'bytes', label: '웹 디스크' }, web_out: { unit: 'bytes_month', label: '월간 웹 아웃바운드' },
|
||||
mail_storage: { unit: 'bytes', label: '메일 저장 공간' }, mail_msgs: { unit: 'count_month', label: '월간 메일 통수(송신+수신)' }, mailboxes: { unit: 'count', label: '메일함 수' },
|
||||
mail_services: { unit: 'count', label: '독립 메일 서비스' }, source: { unit: 'count', label: 'Source 옵션' }, backup: { unit: 'count', label: '사이트 백업 옵션' },
|
||||
});
|
||||
export const DEFAULT_WARN_LEVELS = [70, 80, 90, 95];
|
||||
|
||||
export const MIGRATIONS = [{ id: 'lib_quota_0001', sql: `
|
||||
CREATE TABLE IF NOT EXISTS quota_entitlements (
|
||||
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, amount INTEGER NOT NULL, source_type TEXT, source_id TEXT, product_version_id TEXT,
|
||||
starts_at TEXT NOT NULL, ends_at TEXT, note TEXT, created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS qe_group ON quota_entitlements(group_id, resource);
|
||||
CREATE TABLE IF NOT EXISTS quota_allocations (
|
||||
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, amount INTEGER NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'in_use', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, return_due_at TEXT,
|
||||
UNIQUE(group_id, resource, subject_type, subject_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS quota_reservations (
|
||||
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, amount INTEGER NOT NULL, state TEXT NOT NULL DEFAULT 'reserved',
|
||||
subject_type TEXT, subject_id TEXT, job_id TEXT, reason TEXT, created_at TEXT NOT NULL, expires_at TEXT, committed_at TEXT, released_at TEXT, return_due_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS qr_group ON quota_reservations(group_id, resource, state);
|
||||
CREATE TABLE IF NOT EXISTS quota_usage (
|
||||
group_id TEXT NOT NULL, resource TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, used INTEGER NOT NULL, period_start TEXT, measured_at TEXT NOT NULL, source TEXT,
|
||||
PRIMARY KEY(group_id, resource, subject_type, subject_id)
|
||||
);` }];
|
||||
|
||||
export class QuotaError extends Error { constructor(code, message, detail) { super(message); this.code = code; this.status = 409; this.detail = detail; } }
|
||||
|
||||
/** 권리 부여(주문 승인 후). ends_at 이 없으면 무기한(갱신 때 갱신). */
|
||||
export function grantEntitlement(store, { groupId, resource, amount, sourceType = 'order', sourceId = null, productVersionId = null, startsAt = null, endsAt = null, note = null }) {
|
||||
if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`);
|
||||
const id = newId('qe_'); store.run('INSERT INTO quota_entitlements(id, group_id, resource, amount, source_type, source_id, product_version_id, starts_at, ends_at, note, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, groupId, resource, Math.round(amount), sourceType, sourceId, productVersionId, startsAt || nowIso(), endsAt, note, nowIso()); return id;
|
||||
}
|
||||
/** 권리 종료(해지·축소 적용) */
|
||||
export function endEntitlement(store, id, at = nowIso()) { return store.run('UPDATE quota_entitlements SET ends_at=? WHERE id=? AND (ends_at IS NULL OR ends_at>?)', at, id, at).changes; }
|
||||
|
||||
const sumSql = { purchased: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_entitlements WHERE group_id=? AND resource=? AND starts_at<=? AND (ends_at IS NULL OR ends_at>?)', allocated: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_allocations WHERE group_id=? AND resource=? AND state IN (\'in_use\',\'returning\')', reserved: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_reservations WHERE group_id=? AND resource=? AND state=\'reserved\' AND (expires_at IS NULL OR expires_at>?)' };
|
||||
/** 자원 하나의 수치 */
|
||||
export function figures(store, groupId, resource, at = nowIso()) {
|
||||
const purchased = store.get(sumSql.purchased, groupId, resource, at, at).n; const allocated = store.get(sumSql.allocated, groupId, resource).n; const reserved = store.get(sumSql.reserved, groupId, resource, at).n;
|
||||
const used = store.get('SELECT COALESCE(SUM(used),0) AS n FROM quota_usage WHERE group_id=? AND resource=?', groupId, resource).n;
|
||||
return { resource, unit: RESOURCES[resource].unit, label: RESOURCES[resource].label, purchased, allocated, reserved, unallocated: Math.max(0, purchased - allocated - reserved), used };
|
||||
}
|
||||
/** 그룹 전체 요약(§13.2) */
|
||||
export function summary(store, groupId, at = nowIso()) { return Object.keys(RESOURCES).map((r) => figures(store, groupId, r, at)); }
|
||||
|
||||
/**
|
||||
* 예약(§13.3). 트랜잭션 안에서 가용량을 검사한다. 부족하면 QuotaError('quota_insufficient', shortage).
|
||||
* 같은 subject·resource 의 활성 예약이 있으면 그것을 돌려준다(중복 예약 금지).
|
||||
*/
|
||||
export function reserve(store, { groupId, resource, amount, subjectType = null, subjectId = null, jobId = null, reason = null, ttlMs = 24 * 3600 * 1000 }) {
|
||||
if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`);
|
||||
amount = Math.round(amount); if (amount <= 0) throw new QuotaError('bad_amount', '예약량은 0보다 커야 합니다');
|
||||
return store.tx(() => {
|
||||
if (subjectId) { const ex = store.get('SELECT * FROM quota_reservations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=? AND state=\'reserved\'', groupId, resource, subjectType, subjectId); if (ex) return { ...ex, existing: true }; }
|
||||
const f = figures(store, groupId, resource);
|
||||
if (amount > f.unallocated) throw new QuotaError('quota_insufficient', `${f.label}이(가) 부족합니다 (필요 ${amount}, 사용 가능 ${f.unallocated})`, { resource, requested: amount, available: f.unallocated, shortage: amount - f.unallocated, purchased: f.purchased, allocated: f.allocated, reserved: f.reserved });
|
||||
const id = newId('qr_'); const now = nowIso();
|
||||
store.run('INSERT INTO quota_reservations(id, group_id, resource, amount, state, subject_type, subject_id, job_id, reason, created_at, expires_at) VALUES(?,?,?,?,\'reserved\',?,?,?,?,?,?)', id, groupId, resource, amount, subjectType, subjectId, jobId, reason, now, ttlMs ? new Date(Date.now() + ttlMs).toISOString() : null);
|
||||
return store.get('SELECT * FROM quota_reservations WHERE id=?', id);
|
||||
});
|
||||
}
|
||||
/** 예약 확정 → 배정(in_use). 배정이 이미 있으면 양을 더한다. */
|
||||
export function commit(store, reservationId) {
|
||||
return store.tx(() => {
|
||||
const r = store.get('SELECT * FROM quota_reservations WHERE id=?', reservationId); if (!r) throw new QuotaError('reservation_not_found', '예약이 없습니다');
|
||||
if (r.state === 'in_use') return r;
|
||||
if (r.state !== 'reserved') throw new QuotaError('reservation_not_active', `예약 상태(${r.state})에서는 확정할 수 없습니다`);
|
||||
const now = nowIso();
|
||||
store.run('UPDATE quota_reservations SET state=\'in_use\', committed_at=? WHERE id=?', now, r.id);
|
||||
const a = store.get('SELECT * FROM quota_allocations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', r.group_id, r.resource, r.subject_type || 'none', r.subject_id || r.id);
|
||||
if (a) store.run('UPDATE quota_allocations SET amount=amount+?, state=\'in_use\', updated_at=? WHERE id=?', r.amount, now, a.id);
|
||||
else store.run('INSERT INTO quota_allocations(id, group_id, resource, subject_type, subject_id, amount, state, created_at, updated_at) VALUES(?,?,?,?,?,?,\'in_use\',?,?)', newId('qa_'), r.group_id, r.resource, r.subject_type || 'none', r.subject_id || r.id, r.amount, now, now);
|
||||
return store.get('SELECT * FROM quota_reservations WHERE id=?', r.id);
|
||||
});
|
||||
}
|
||||
/** 예약 반환(실패·취소·되돌리기). returning=true 면 정리 전 '반환 중' 으로 두고 returnDueAt 을 표시한다(§11.2 "반환 예정 시각"). */
|
||||
export function release(store, reservationId, { returning = false, returnDueAt = null } = {}) {
|
||||
const r = store.get('SELECT * FROM quota_reservations WHERE id=?', reservationId); if (!r) return null;
|
||||
if (r.state === 'returned') return r;
|
||||
if (returning) store.run('UPDATE quota_reservations SET state=\'returning\', return_due_at=? WHERE id=?', returnDueAt, r.id);
|
||||
else store.run('UPDATE quota_reservations SET state=\'returned\', released_at=? WHERE id=?', nowIso(), r.id);
|
||||
return store.get('SELECT * FROM quota_reservations WHERE id=?', r.id);
|
||||
}
|
||||
/** 만료된 예약 자동 반환(주기 작업). 반환된 예약 목록을 돌려줘 호출자가 알림을 만든다. */
|
||||
export function expireReservations(store, at = nowIso()) {
|
||||
const rows = store.all('SELECT * FROM quota_reservations WHERE state=\'reserved\' AND expires_at IS NOT NULL AND expires_at<=?', at);
|
||||
for (const r of rows) store.run('UPDATE quota_reservations SET state=\'returned\', released_at=? WHERE id=?', at, r.id);
|
||||
return rows;
|
||||
}
|
||||
/** 대상의 배정 읽기 */
|
||||
export function allocationOf(store, groupId, resource, subjectType, subjectId) { return store.get('SELECT * FROM quota_allocations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', groupId, resource, subjectType, subjectId) || null; }
|
||||
/**
|
||||
* 배정량 변경(§13.3). 늘릴 때는 미배정량 안에서, 줄일 때는 실제 사용량(usedNow)과 이번 달 누적(monthUsed, 월간 자원)보다 작게 할 수 없다.
|
||||
* @returns {{allocation:object, before:number, after:number}}
|
||||
*/
|
||||
export function setAllocation(store, { groupId, resource, subjectType, subjectId, amount, usedNow = 0, monthUsed = 0 }) {
|
||||
if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`);
|
||||
amount = Math.round(amount); if (amount < 0) throw new QuotaError('bad_amount', '배정량은 0 이상이어야 합니다');
|
||||
return store.tx(() => {
|
||||
const a = allocationOf(store, groupId, resource, subjectType, subjectId); const before = a ? a.amount : 0; const f = figures(store, groupId, resource);
|
||||
const floor = RESOURCES[resource].unit.endsWith('_month') ? Math.max(usedNow, monthUsed) : usedNow;
|
||||
if (amount < floor) throw new QuotaError('allocation_below_usage', `현재 사용량(${floor})보다 작게 줄일 수 없습니다. 먼저 ${floor - amount} 만큼 정리하세요`, { resource, requested: amount, floor, cleanupNeeded: floor - amount });
|
||||
const delta = amount - before;
|
||||
if (delta > f.unallocated) throw new QuotaError('quota_insufficient', `${f.label} 미배정량(${f.unallocated})을 넘습니다`, { resource, requested: delta, available: f.unallocated, shortage: delta - f.unallocated });
|
||||
const now = nowIso();
|
||||
if (a) store.run('UPDATE quota_allocations SET amount=?, state=\'in_use\', updated_at=? WHERE id=?', amount, now, a.id);
|
||||
else store.run('INSERT INTO quota_allocations(id, group_id, resource, subject_type, subject_id, amount, state, created_at, updated_at) VALUES(?,?,?,?,?,?,\'in_use\',?,?)', newId('qa_'), groupId, resource, subjectType, subjectId, amount, now, now);
|
||||
return { allocation: allocationOf(store, groupId, resource, subjectType, subjectId), before, after: amount };
|
||||
});
|
||||
}
|
||||
/** 배정 반환 시작(반환 중) / 완료(삭제). §14 사이트 수 반환은 모든 조건이 충족된 뒤 finish 를 부른다. */
|
||||
export function beginReturn(store, allocationId, returnDueAt = null) { return store.run('UPDATE quota_allocations SET state=\'returning\', return_due_at=?, updated_at=? WHERE id=? AND state=\'in_use\'', returnDueAt, nowIso(), allocationId).changes; }
|
||||
export function finishReturn(store, allocationId) { return store.run('DELETE FROM quota_allocations WHERE id=?', allocationId).changes; }
|
||||
export function cancelReturn(store, allocationId) { return store.run('UPDATE quota_allocations SET state=\'in_use\', return_due_at=NULL, updated_at=? WHERE id=? AND state=\'returning\'', nowIso(), allocationId).changes; }
|
||||
/** 실제 사용량 기록(계측 결과). 월간 자원은 period_start(KST 월 시작) 를 함께 둔다. */
|
||||
export function recordUsage(store, { groupId, resource, subjectType, subjectId, used, periodStart = null, source = null, measuredAt = nowIso() }) {
|
||||
store.run('INSERT INTO quota_usage(group_id, resource, subject_type, subject_id, used, period_start, measured_at, source) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(group_id, resource, subject_type, subject_id) DO UPDATE SET used=excluded.used, period_start=excluded.period_start, measured_at=excluded.measured_at, source=excluded.source', groupId, resource, subjectType, subjectId, Math.round(used), periodStart, measuredAt, source);
|
||||
}
|
||||
export function usageOf(store, groupId, resource, subjectType, subjectId) { return store.get('SELECT * FROM quota_usage WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', groupId, resource, subjectType, subjectId) || null; }
|
||||
/** 서비스별 배정·사용 목록(화면: 사이트별 배정량·사용량) */
|
||||
export function listAllocations(store, groupId, resource = null) {
|
||||
return store.all(`SELECT a.*, u.used, u.measured_at FROM quota_allocations a LEFT JOIN quota_usage u ON u.group_id=a.group_id AND u.resource=a.resource AND u.subject_type=a.subject_type AND u.subject_id=a.subject_id WHERE a.group_id=? ${resource ? 'AND a.resource=?' : ''} ORDER BY a.resource, a.created_at`, ...(resource ? [groupId, resource] : [groupId]));
|
||||
}
|
||||
export function listReservations(store, groupId, { active = true } = {}) { return store.all(`SELECT * FROM quota_reservations WHERE group_id=? ${active ? 'AND state IN (\'reserved\',\'returning\')' : ''} ORDER BY created_at DESC`, groupId); }
|
||||
/**
|
||||
* 경고 구간(§13.4). 넘은 가장 높은 구간을 돌려준다(0 = 정상, 100 = 제한).
|
||||
* @param {number} used @param {number} limit 0 이면 제한 없음 → 0
|
||||
*/
|
||||
export function warnLevel(used, limit, levels = DEFAULT_WARN_LEVELS) {
|
||||
if (!limit || limit <= 0) return 0; const pct = (used / limit) * 100; if (pct >= 100) return 100;
|
||||
let lvl = 0; for (const l of levels) if (pct >= l) lvl = l; return lvl;
|
||||
}
|
||||
export const percent = (used, limit) => (limit > 0 ? Math.min(999, Math.round((used / limit) * 1000) / 10) : 0);
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* lib/redact.mjs — 비밀값 가리기(§37).
|
||||
*
|
||||
* 감사 기록·알림·작업 결과·오류 메시지·로그에 들어가는 모든 객체는 이 함수를 거친다.
|
||||
* - 키 이름이 비밀로 보이면(password, secret, token, key, credential, cookie, authorization, recovery, otp, privkey …) 값을 '[redacted]' 로 바꾼다.
|
||||
* - 문자열 값이 비밀 형태(sk-…, re_…, au_…, JWT, 40자 이상 base64url/hex, PEM 블록)면 가린다.
|
||||
* - 깊이 6 까지 재귀. 배열·객체·문자열 모두.
|
||||
*/
|
||||
const KEY_RE = /pass(word|wd)?|secret|token|api[-_]?key|private[-_]?key|privkey|credential|cookie|authorization|recovery|otp|totp|session|vault|dkim|\bkey\b/i;
|
||||
const VALUE_RES = [
|
||||
/^(sk|rk|re|au|aw|ag|ghp|gho|xox[a-z])[-_][A-Za-z0-9_-]{16,}$/, // 서비스 키 접두어 형식
|
||||
/^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}$/, // JWT
|
||||
/-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM 개인키
|
||||
/^[A-Za-z0-9_-]{40,}$/, // 긴 무작위 토큰
|
||||
/^[a-f0-9]{48,}$/i, // 긴 hex 비밀
|
||||
];
|
||||
const KEEP_KEYS = /^(id|_id|site_id|group_id|user_id|job_id|idempotency_?key|incident_?key|public_?key_?fingerprint|key_?name|keyName|kind|resource)$/i;
|
||||
|
||||
/** 값 하나가 비밀 형태인지 */
|
||||
export function looksSecret(v) { if (typeof v !== 'string' || v.length < 12) return false; return VALUE_RES.some((re) => re.test(v)); }
|
||||
|
||||
/**
|
||||
* 객체/배열/문자열에서 비밀값을 가린다. 원본은 바꾸지 않는다.
|
||||
* @param {*} v
|
||||
* @param {number} depth 내부 재귀 깊이
|
||||
*/
|
||||
export function redact(v, depth = 0) {
|
||||
if (depth > 6) return '[depth]';
|
||||
if (v == null) return v;
|
||||
if (typeof v === 'string') return looksSecret(v) ? '[redacted]' : v;
|
||||
if (Array.isArray(v)) return v.map((x) => redact(x, depth + 1));
|
||||
if (typeof v === 'object') {
|
||||
const out = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
if (!KEEP_KEYS.test(k) && KEY_RE.test(k)) { out[k] = val == null || val === '' ? val : '[redacted]'; continue; }
|
||||
out[k] = redact(val, depth + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
/** 오류 객체를 기록용 안전 문자열로 */
|
||||
export function safeError(e) { const m = e && e.message ? String(e.message) : String(e || ''); return redact(m.replace(/(Bearer\s+)[A-Za-z0-9._-]+/g, '$1[redacted]')); }
|
||||
/** 비밀값의 앞뒤만 보이는 힌트(예: au_12…9f) — 화면에 "등록됨" 을 보여 주되 값은 못 알아보게 */
|
||||
export function hint(secret) { const s = String(secret || ''); if (s.length < 10) return s ? '••••' : ''; return `${s.slice(0, 4)}…${s.slice(-2)}`; }
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* lib/session.mjs — DB 세션(기기 목록·개별/전체 종료 §3.2)과 CSRF.
|
||||
*
|
||||
* 쿠키에는 무작위 토큰만 두고 서버는 sha256 해시로 찾는다(DB 가 유출돼도 세션을 위조할 수 없다).
|
||||
* kind: 'customer'(고객 포털) | 'operator'(운영자 콘솔) | 'setup'. 종류가 다른 세션으로는 상대편 화면을 열 수 없다(§25).
|
||||
* CSRF: 세션마다 무작위 csrf 값을 두고 변경 요청은 `X-CSRF` 헤더로 보낸다(상수 시간 비교).
|
||||
* 재인증(§25 고위험 작업): reauth_at 을 두고 requireRecentAuth(maxAgeMs) 로 검사한다.
|
||||
*/
|
||||
import { randomToken, sha256, safeEqual, newId } from './crypto.mjs';
|
||||
import { nowIso, parseJson, toJson } from './db.mjs';
|
||||
|
||||
export const MIGRATIONS = [{ id: 'lib_sessions_0001', sql: `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL, kind TEXT NOT NULL,
|
||||
csrf TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, reauth_at TEXT,
|
||||
ip TEXT, ua TEXT, label TEXT, revoked_at TEXT, data_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id, kind);` }];
|
||||
|
||||
const COOKIE = { customer: 'aw_session', operator: 'aw_ops', setup: 'aw_setup' };
|
||||
export const cookieName = (kind) => COOKIE[kind] || 'aw_session';
|
||||
|
||||
/** 세션 만들기. 반환 { id, token(원문, 쿠키용), csrf, expiresAt } */
|
||||
export function createSession(store, { userId, kind = 'customer', ip = '', ua = '', days = 30, data = null, reauth = true }) {
|
||||
const token = randomToken(32); const id = newId('s_'); const now = nowIso();
|
||||
const expiresAt = new Date(Date.now() + days * 86400000).toISOString(); const csrf = randomToken(24);
|
||||
store.run('INSERT INTO sessions(id, token_hash, user_id, kind, csrf, created_at, expires_at, last_seen_at, reauth_at, ip, ua, label, data_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
id, sha256(token), userId, kind, csrf, now, expiresAt, now, reauth ? now : null, ip, String(ua || '').slice(0, 200), deviceLabel(ua), toJson(data));
|
||||
return { id, token, csrf, expiresAt };
|
||||
}
|
||||
/** 토큰(쿠키 값)으로 세션을 찾는다. 만료·종료된 세션은 null. 찾으면 last_seen 을 갱신(1분에 한 번만). */
|
||||
export function resolveSession(store, token, kind) {
|
||||
if (!token) return null;
|
||||
const s = store.get('SELECT * FROM sessions WHERE token_hash=? AND kind=?', sha256(token), kind);
|
||||
if (!s || s.revoked_at || Date.parse(s.expires_at) < Date.now()) return null;
|
||||
if (Date.now() - Date.parse(s.last_seen_at) > 60000) store.run('UPDATE sessions SET last_seen_at=? WHERE id=?', nowIso(), s.id);
|
||||
return { ...s, data: parseJson(s.data_json, {}) };
|
||||
}
|
||||
/** 세션 데이터(현재 그룹 등) 갱신 */
|
||||
export function updateSessionData(store, id, patch) { const s = store.get('SELECT data_json FROM sessions WHERE id=?', id); const d = { ...parseJson(s?.data_json, {}), ...patch }; store.run('UPDATE sessions SET data_json=? WHERE id=?', toJson(d), id); return d; }
|
||||
/** 재인증 기록(고위험 작업 전 비밀번호 재확인) */
|
||||
export function markReauth(store, id) { store.run('UPDATE sessions SET reauth_at=? WHERE id=?', nowIso(), id); }
|
||||
/** 최근 재인증 여부 */
|
||||
export function recentlyAuthed(session, maxAgeMs = 10 * 60 * 1000) { return !!(session && session.reauth_at && Date.now() - Date.parse(session.reauth_at) < maxAgeMs); }
|
||||
/** 개별 세션 종료 */
|
||||
export function revokeSession(store, id, userId) { return store.run('UPDATE sessions SET revoked_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL', nowIso(), id, userId).changes; }
|
||||
/** 다른 모든 세션 종료(현재 세션 제외 가능). kind 를 주면 그 종류만 */
|
||||
export function revokeOtherSessions(store, userId, keepId = null, kind = null) {
|
||||
return store.run(`UPDATE sessions SET revoked_at=? WHERE user_id=? AND revoked_at IS NULL ${keepId ? 'AND id<>?' : ''} ${kind ? 'AND kind=?' : ''}`, ...[nowIso(), userId, ...(keepId ? [keepId] : []), ...(kind ? [kind] : [])]).changes;
|
||||
}
|
||||
/** 사용자의 활성 세션 목록(기기 화면용, 토큰 없음) */
|
||||
export function listSessions(store, userId, kind) {
|
||||
return store.all('SELECT id, created_at, last_seen_at, expires_at, ip, label FROM sessions WHERE user_id=? AND kind=? AND revoked_at IS NULL AND expires_at>? ORDER BY last_seen_at DESC', userId, kind, nowIso());
|
||||
}
|
||||
/** CSRF 검사 */
|
||||
export function csrfOk(session, token) { return !!(session && token && safeEqual(session.csrf, token)); }
|
||||
/** UA 에서 기기 표시 이름 (대략) */
|
||||
function deviceLabel(ua) {
|
||||
const s = String(ua || ''); const os = /Windows/.test(s) ? 'Windows' : /Android/.test(s) ? 'Android' : /iPhone|iPad/.test(s) ? 'iOS' : /Mac OS/.test(s) ? 'macOS' : /Linux/.test(s) ? 'Linux' : '기기';
|
||||
const br = /Edg\//.test(s) ? 'Edge' : /Chrome\//.test(s) ? 'Chrome' : /Safari\//.test(s) ? 'Safari' : /Firefox\//.test(s) ? 'Firefox' : '브라우저';
|
||||
return `${os} · ${br}`;
|
||||
}
|
||||
/** 만료·종료된 세션 정리(주기 작업) */
|
||||
export function purgeSessions(store, olderThanDays = 7) { const cut = new Date(Date.now() - olderThanDays * 86400000).toISOString(); return store.run('DELETE FROM sessions WHERE expires_at<? OR (revoked_at IS NOT NULL AND revoked_at<?)', cut, cut).changes; }
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* lib/status.mjs — 사용자용 상태 어휘(§2.2)와 전이 규칙.
|
||||
*
|
||||
* 세 종류의 상태를 분리한다(§2.2 "하나의 상태로 합치지 않는다").
|
||||
* JOB_STATUS 작업(배포·복원·이전·주문…)의 진행 상태 — 작업 엔진(lib/jobs)이 쓴다
|
||||
* SERVICE_STATE 서비스(사이트·메일)의 생명주기 상태(§23.3)
|
||||
* HEALTH 외부 확인 건강 상태(§31)
|
||||
* 코드(영문)로 저장하고 화면은 label() 로 한국어를 얻는다.
|
||||
*/
|
||||
export const JOB_STATUS = Object.freeze({
|
||||
NOT_READY: 'not_ready', NEEDS_SETUP: 'needs_setup', AWAITING_APPROVAL: 'awaiting_approval', AWAITING_PAYMENT: 'awaiting_payment',
|
||||
QUEUED: 'queued', RUNNING: 'running', AWAITING_EXTERNAL: 'awaiting_external', VERIFYING: 'verifying', PAUSED: 'paused',
|
||||
DONE: 'done', FAILED: 'failed', REVERTING: 'reverting', REVERTED: 'reverted', NEEDS_USER: 'needs_user', CANCELLED: 'cancelled',
|
||||
});
|
||||
export const JOB_LABEL = Object.freeze({
|
||||
not_ready: '준비 전', needs_setup: '설정 필요', awaiting_approval: '승인 대기', awaiting_payment: '결제 대기', queued: '처리 대기', running: '진행 중',
|
||||
awaiting_external: '외부 작업 대기', verifying: '검증 중', paused: '일시 중지', done: '완료', failed: '실패', reverting: '되돌리는 중', reverted: '되돌림 완료',
|
||||
needs_user: '사용자 확인 필요', cancelled: '취소됨',
|
||||
});
|
||||
/** 끝난 상태(더 바뀌지 않음) */
|
||||
export const TERMINAL = new Set(['done', 'failed', 'reverted', 'cancelled']);
|
||||
/** 사용자가 취소할 수 있는 상태 */
|
||||
export const CANCELLABLE = new Set(['not_ready', 'needs_setup', 'awaiting_approval', 'awaiting_payment', 'queued', 'awaiting_external', 'needs_user', 'paused']);
|
||||
|
||||
export const SERVICE_STATE = Object.freeze({
|
||||
DRAFT: 'draft', BUILDING: 'building', DEPLOYING: 'deploying', LIVE: 'live', NEEDS_DOMAIN: 'needs_domain', PAUSED: 'paused',
|
||||
CANCEL_SCHEDULED: 'cancel_scheduled', READONLY_RETENTION: 'readonly_retention', PENDING_DELETE: 'pending_delete', DELETED: 'deleted', DELETE_HOLD: 'delete_hold',
|
||||
});
|
||||
export const SERVICE_LABEL = Object.freeze({
|
||||
draft: '초안', building: '제작 중', deploying: '배포 중', live: '운영 중', needs_domain: '도메인 설정 필요', paused: '일시 중지', cancel_scheduled: '해지 예약',
|
||||
readonly_retention: '읽기 전용 보존', pending_delete: '삭제 대기', deleted: '삭제 완료', delete_hold: '삭제 보류',
|
||||
});
|
||||
export const HEALTH = Object.freeze({ OK: 'ok', DEGRADED: 'degraded', PARTIAL: 'partial_outage', DOWN: 'down', UNKNOWN: 'unknown', STALE: 'stale', NOT_PROVIDED: 'not_provided' });
|
||||
export const HEALTH_LABEL = Object.freeze({ ok: '정상', degraded: '성능 저하', partial_outage: '일부 장애', down: '중단', unknown: '확인 전', stale: '정보 확인 지연', not_provided: '제공되지 않음' });
|
||||
|
||||
/** 배포 완료 세분(§11.2) */
|
||||
export const DEPLOY_VERIFY = Object.freeze({ VERIFIED: 'verified', NEEDS_DOMAIN: 'needs_domain', PUBLIC: 'public' });
|
||||
export const DEPLOY_VERIFY_LABEL = Object.freeze({ verified: '배포 검증 완료', needs_domain: '도메인 설정 필요', public: '공개 운영 중' });
|
||||
|
||||
/** 할당 상태(§13.3) */
|
||||
export const ALLOC_LABEL = Object.freeze({ available: '사용 가능', reserved: '예약됨', in_use: '사용 중', returning: '반환 중', returned: '반환됨' });
|
||||
|
||||
/** 코드 → 한국어. 알 수 없는 코드는 그대로(화면에서 눈에 띄게) */
|
||||
export function label(kind, code) {
|
||||
const map = { job: JOB_LABEL, service: SERVICE_LABEL, health: HEALTH_LABEL, deploy: DEPLOY_VERIFY_LABEL, alloc: ALLOC_LABEL }[kind] || {};
|
||||
return map[code] || code || '';
|
||||
}
|
||||
/**
|
||||
* 상태 정보가 오래됐는지(§2.2 `정보 확인 지연`). 마지막 갱신으로부터 staleAfterMs 가 지났으면 true.
|
||||
* @param {string} updatedAt ISO
|
||||
* @param {number} staleAfterMs 기본 5분
|
||||
*/
|
||||
export function isStale(updatedAt, staleAfterMs = 5 * 60 * 1000, now = Date.now()) { if (!updatedAt) return true; return now - Date.parse(updatedAt) > staleAfterMs; }
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* lib/test.mjs — 작은 테스트 러너(의존성 없음). 형제 제품(AriMail)과 같은 방식: 파일을 node 로 직접 실행하면 순서대로 돈다.
|
||||
* import { test, eq, ok, rejects, tmpDir, done } from '../lib/test.mjs';
|
||||
* await test('이름', async () => { ... }); 실패하면 즉시 요약을 내고 종료 코드 1.
|
||||
* done(); 마지막에 요약 출력.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
let passed = 0; let failed = 0; const dirs = [];
|
||||
export async function test(name, fn) {
|
||||
const t0 = Date.now();
|
||||
try { await fn(); passed++; console.log(` ✔ ${name} (${Date.now() - t0}ms)`); }
|
||||
catch (e) { failed++; console.error(` ✘ ${name}\n ${e && e.stack ? e.stack.split('\n').slice(0, 6).join('\n ') : e}`); process.exitCode = 1; }
|
||||
}
|
||||
export function eq(a, b, msg = '') { const sa = JSON.stringify(a); const sb = JSON.stringify(b); if (sa !== sb) throw new Error(`${msg ? msg + ': ' : ''}기대 ${sb} 실제 ${sa}`); }
|
||||
export function ok(v, msg = '') { if (!v) throw new Error(`${msg || '참이어야 합니다'} (실제 ${JSON.stringify(v)})`); }
|
||||
export async function rejects(fn, codeOrRe, msg = '') {
|
||||
try { await fn(); } catch (e) { if (codeOrRe == null) return e; const s = `${e.code || ''} ${e.message || ''}`; if (codeOrRe instanceof RegExp ? codeOrRe.test(s) : (e.code === codeOrRe || String(e.message).includes(codeOrRe))) return e; throw new Error(`${msg || '다른 오류'}: 기대 ${codeOrRe} 실제 ${s}`); }
|
||||
throw new Error(`${msg || '예외가 나야 합니다'}`);
|
||||
}
|
||||
/** 테스트용 임시 디렉터리(종료 시 삭제) */
|
||||
export function tmpDir(prefix = 'aw-test-') { const d = mkdtempSync(join(tmpdir(), prefix)); dirs.push(d); return d; }
|
||||
export function done() { for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }); } catch (_) {} } console.log(`${failed ? '✘' : '✔'} 통과 ${passed} · 실패 ${failed}`); if (failed) process.exit(1); }
|
||||
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* lib/vault.mjs — 비밀값 보관소(AES-256-GCM).
|
||||
*
|
||||
* 서비스 키·API 키·Agent 비밀·SSO 공유 비밀처럼 "저장 후 다시 볼 수 없고 재발급만 되는" 값을 둔다(§37).
|
||||
* - 암호화 키: 데이터 디렉터리의 `vault.key`(32바이트, 0600). 없으면 처음 열 때 만든다. 환경 변수 ARIWEB_VAULT_KEY(hex) 가 있으면 그것을 쓴다(재해복구 때 같은 키로 복원).
|
||||
* - 저장 파일: `vault.json` — { items: { name: { iv, tag, data, hint, updatedAt, kind } } }. 원자적 쓰기(tmp → rename).
|
||||
* - API: set(name, secret, {kind}) / get(name) / has(name) / remove(name) / list() (이름·힌트·시각만, 값 없음)
|
||||
*/
|
||||
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { hint } from './redact.mjs';
|
||||
|
||||
export function openVault(dir) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const keyFile = join(dir, 'vault.key'); const file = join(dir, 'vault.json');
|
||||
let key;
|
||||
if (process.env.ARIWEB_VAULT_KEY) key = Buffer.from(process.env.ARIWEB_VAULT_KEY, 'hex');
|
||||
else if (existsSync(keyFile)) key = Buffer.from(readFileSync(keyFile, 'utf8').trim(), 'hex');
|
||||
else { key = randomBytes(32); writeFileSync(keyFile, key.toString('hex'), { mode: 0o600 }); }
|
||||
if (key.length !== 32) throw new Error('vault 키 길이가 올바르지 않습니다(32바이트 hex 필요)');
|
||||
const load = () => { try { return JSON.parse(readFileSync(file, 'utf8')); } catch (_) { return { items: {} }; } };
|
||||
const save = (d) => { const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(d, null, 1), { mode: 0o600 }); renameSync(tmp, file); try { chmodSync(file, 0o600); } catch (_) {} };
|
||||
return {
|
||||
dir, file,
|
||||
set(name, secret, { kind = 'secret' } = {}) {
|
||||
const iv = randomBytes(12); const c = createCipheriv('aes-256-gcm', key, iv);
|
||||
const data = Buffer.concat([c.update(String(secret), 'utf8'), c.final()]);
|
||||
const d = load(); d.items[name] = { kind, iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), data: data.toString('base64'), hint: hint(secret), updatedAt: new Date().toISOString() }; save(d);
|
||||
return { name, hint: d.items[name].hint };
|
||||
},
|
||||
get(name) {
|
||||
const it = load().items[name]; if (!it) return null;
|
||||
const dc = createDecipheriv('aes-256-gcm', key, Buffer.from(it.iv, 'base64')); dc.setAuthTag(Buffer.from(it.tag, 'base64'));
|
||||
return Buffer.concat([dc.update(Buffer.from(it.data, 'base64')), dc.final()]).toString('utf8');
|
||||
},
|
||||
has: (name) => !!load().items[name],
|
||||
remove(name) { const d = load(); const had = !!d.items[name]; delete d.items[name]; save(d); return had; },
|
||||
list() { return Object.entries(load().items).map(([name, it]) => ({ name, kind: it.kind, hint: it.hint, updatedAt: it.updatedAt })); },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user