diff --git a/docs/maestro-workflow/README.md b/docs/maestro-workflow/README.md index 93856e6..438d974 100644 --- a/docs/maestro-workflow/README.md +++ b/docs/maestro-workflow/README.md @@ -14,7 +14,8 @@ Maestro Harmony 제품군의 범용 승인·결정·이력 앱. 구현은 [`work - 레인(결정 채널) 대시보드: 승인/반려 시트, 프리셋(spend/publish) 표시, 이력 뷰 - 엄격 모드 대시보드: 토큰 게이트 + WS 첫 메시지 인증 + 자동 재연결 (2026-08-03 스펙) - actor 토큰 WS 구독: 자기 결정만 스코프 수신, revoke 시 소켓 종료 (2026-08-04 스펙) -- 범위 밖: Policy/자동승인, Delegation, 에이전트 decider, executor 실행, 다중 운영자 +- 다중 운영자: 운영자 레지스트리 + decidedBy 신원 강제 (2026-08-04 스펙) +- 범위 밖: Policy/자동승인, Delegation, 에이전트 decider, executor 실행, RBAC/승인선 ## 후속 구상 diff --git a/docs/superpowers/specs/2026-08-04-workflow-multi-operator-design.md b/docs/superpowers/specs/2026-08-04-workflow-multi-operator-design.md new file mode 100644 index 0000000..89f3e71 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-workflow-multi-operator-design.md @@ -0,0 +1,52 @@ +# Maestro Workflow 다중 운영자 설계 (운영자 레지스트리 + 신원 기록) + +- 날짜: 2026-08-04 +- 상태: 확정 (마지막 예약 스펙 — actor 레지스트리 패턴의 운영자판) +- 범위: `workflow/` 하위만 + +## 0. 목표 + +단일 서버 토큰 = 운영자 전권이던 모델을 분리한다: **root(서버 토큰)**는 +관리 전용으로 물러나고, 결정은 **개별 운영자 토큰**으로 수행하며 신원이 +`decidedBy`에 자동 기록된다. + +## 1. 권한 모델 (엄격 모드) + +| 토큰 | 신원 | 허용 | +| --- | --- | --- | +| 서버 토큰 | `root` | 전부 (관리: actor/운영자 등록·폐기·목록 + 결정·조회) | +| 운영자 토큰 | `operatorId` | 결정·조회 (pending 목록, decide, history, chain) + WS 전체 스트림 | +| actor 토큰 | `actorId` | 현행 유지 (요청 생성, 자기 결정 폴링/ack/WS) | + +open 모드(토큰 미설정)는 현행 무인증 동작 유지 — `decidedBy`는 body 값 +(없으면 'operator')을 그대로 쓴다. + +## 2. 구성요소 + +- `server/operators.js`: actors.js 미러(heartbeat 없음) — 등록(upsert=토큰 + 회전, sha256 해시 저장), findOperatorByToken, revoke(tokenHash null), + 목록. 스토어 `MAESTRO_WORKFLOW_OPERATOR_STORE_PATH` + (기본 `.maestro-workflow-operators.json`). +- `auth.js` `resolveOperatorAuth(req)`: open → {mode:'open'}, 서버 토큰 → + {mode:'root', operatorId:'root'}, 운영자 토큰 → {mode:'operator', operatorId}, + 그 외 401. 관리 라우트는 기존 `isServerAuthorized`(root 전용) 유지. +- 라우트: `POST /api/operators/register`·`GET /api/operators`· + `POST /api/operators/:id/revoke` (root 전용, 이력 기록). 운영자급 라우트 + (GET decision-requests, decide, history, chain)는 resolveOperatorAuth로 전환. +- **decidedBy**: 엄격 모드에선 토큰 신원으로 강제(body 위조 무시), + open 모드는 현행 body 사용. +- WS: WORKFLOW_AUTH 판별 순서 서버 토큰 → **운영자 토큰**(operator 스코프, + 전체 스트림, AUTH_OK에 operatorId) → actor 토큰. 운영자 revoke 시 해당 + 소켓 4401(OPERATOR_REVOKED). +- 대시보드: 무변경 — 토큰 게이트에 운영자 토큰을 넣으면 그대로 동작 + (AUTH_OK 추가 필드는 무시됨). + +## 3. 테스트 (`tests/operators.test.mjs`) + +등록/결정/decidedBy 기록, 위조 decidedBy 무시, 관리 라우트 root 전용, +revoke 후 HTTP 401+WS 4401(루트 소켓 유지), 운영자 WS 전체 스트림 수신, +재시작 후 토큰 유효(영속화), open 모드 무회귀(기존 스위트). + +## 4. 비범위 + +RBAC/승인선, 운영자별 채널 필터, 대시보드 신원 표시 UI. diff --git a/workflow/.env.example b/workflow/.env.example index 55e16dd..29d34cd 100644 --- a/workflow/.env.example +++ b/workflow/.env.example @@ -10,3 +10,4 @@ MAESTRO_WORKFLOW_ALLOWED_ORIGINS=http://localhost:5273,http://127.0.0.1:5273 # MAESTRO_WORKFLOW_ACTOR_STORE_PATH=.maestro-workflow-actors.json # MAESTRO_WORKFLOW_DECISION_STORE_PATH=.maestro-workflow-decisions.json # MAESTRO_WORKFLOW_HISTORY_STORE_PATH=.maestro-workflow-history.json +# MAESTRO_WORKFLOW_OPERATOR_STORE_PATH=.maestro-workflow-operators.json diff --git a/workflow/README.md b/workflow/README.md index 318a28c..65614e7 100644 --- a/workflow/README.md +++ b/workflow/README.md @@ -49,7 +49,11 @@ actor도 자신의 actorToken으로 같은 `WORKFLOW_AUTH` 핸드셰이크를 - 토큰은 localStorage에 평문 저장된다 — 로컬 신뢰 기기 전제. TLS 없음, 기본 `HOST=127.0.0.1` 로컬 전용 전제를 유지하라. -- 다중 운영자/권한 분리는 후속 스펙으로 예약한다. +- 다중 운영자 구현됨(2026-08-04): root(서버 토큰)는 관리 전용(actor/운영자 + 등록·폐기), 결정·조회는 개별 운영자 토큰(`POST /api/operators/register`)으로 + 수행하며 엄격 모드에선 `decidedBy`가 토큰 신원으로 강제된다(위조 불가). + 대시보드 토큰 게이트에 운영자 토큰을 넣으면 그대로 동작한다. RBAC/승인선은 + 후속. - 채널 에이전트 연동의 Workflow측 토대는 구현됨(2026-08-04): 프리셋 `email-triage`/`email-reply` 표시, `parentRequestId` 요청 체인 + `GET /api/decision-requests/:id/chain`(운영자 토큰). 커넥터(IMAP/발송) diff --git a/workflow/server.js b/workflow/server.js index c0903c3..c38a814 100644 --- a/workflow/server.js +++ b/workflow/server.js @@ -3,7 +3,14 @@ // 실행: node server.js (기본 http://127.0.0.1:8090) import http from 'node:http'; import { WebSocketServer, WebSocket as WSWebSocket } from 'ws'; -import { PORT, HOST, ALLOWED_ORIGINS, ACTOR_STORE_PATH, DECISION_STORE_PATH, HISTORY_STORE_PATH, SERVER_TOKEN, WS_AUTH_TIMEOUT_MS } from './server/config.js'; +import { PORT, HOST, ALLOWED_ORIGINS, ACTOR_STORE_PATH, DECISION_STORE_PATH, HISTORY_STORE_PATH, OPERATOR_STORE_PATH, SERVER_TOKEN, WS_AUTH_TIMEOUT_MS } from './server/config.js'; +import { + initOperatorStore, + listOperators, + registerOperator, + revokeOperator, + toPublicOperator, +} from './server/operators.js'; import { findActorByToken, heartbeatActor, @@ -13,7 +20,8 @@ import { revokeActor, toPublicActor, } from './server/actors.js'; -import { authorizeActor, isServerAuthorized } from './server/auth.js'; +import { authorizeActor, isServerAuthorized, resolveOperatorAuth } from './server/auth.js'; +import { findOperatorByToken } from './server/operators.js'; import { acknowledgeDecision, countPendingRequests, @@ -31,6 +39,7 @@ import { appendHistory, initHistoryStore, listHistory } from './server/history.j initActorStore(ACTOR_STORE_PATH); initDecisionStore(DECISION_STORE_PATH); initHistoryStore(HISTORY_STORE_PATH); +initOperatorStore(OPERATOR_STORE_PATH); export function sendJson(res, status, body) { res.writeHead(status, { 'Content-Type': 'application/json' }); @@ -164,6 +173,60 @@ async function handleRequest(req, res) { return; } + // ── 운영자 레지스트리 (root 전용) ──────────────────────────────────────── + if (req.method === 'POST' && pathname === '/api/operators/register') { + if (!isServerAuthorized(req)) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + let data; + try { + data = await readJsonBody(req); + } catch (error) { + sendJson(res, error?.code === 'BODY_TOO_LARGE' ? 413 : 400, { error: error?.code || 'Invalid JSON body' }); + return; + } + const registered = registerOperator(data); + if (!registered) { + sendJson(res, 400, { error: 'OPERATOR_ID_REQUIRED' }); + return; + } + recordHistory({ event: 'OPERATOR_REGISTERED', operatorId: registered.operator.operatorId }); + sendJson(res, 200, { + success: true, + item: toPublicOperator(registered.operator), + operatorToken: registered.operatorToken, + }); + return; + } + + if (req.method === 'GET' && pathname === '/api/operators') { + if (!isServerAuthorized(req)) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + sendJson(res, 200, { items: listOperators() }); + return; + } + + const operatorRevokeMatch = pathname.match(/^\/api\/operators\/([^/]+)\/revoke$/); + if (req.method === 'POST' && operatorRevokeMatch) { + if (!isServerAuthorized(req)) { + sendJson(res, 401, { error: 'Unauthorized' }); + return; + } + const operatorId = decodeURIComponent(operatorRevokeMatch[1]); + const operator = revokeOperator(operatorId); + if (!operator) { + sendJson(res, 404, { error: 'OPERATOR_NOT_FOUND' }); + return; + } + recordHistory({ event: 'OPERATOR_REVOKED', operatorId }); + closeOperatorSockets(operatorId); + sendJson(res, 200, { success: true, item: toPublicOperator(operator) }); + return; + } + // ── DecisionRequest (actor 토큰) ───────────────────────────────────────── if (req.method === 'POST' && pathname === '/api/decision-requests') { const auth = authorizeActor(req, res); @@ -209,7 +272,7 @@ async function handleRequest(req, res) { const chainMatch = pathname.match(/^\/api\/decision-requests\/([^/]+)\/chain$/); if (req.method === 'GET' && chainMatch) { - if (!isServerAuthorized(req)) { + if (!resolveOperatorAuth(req).ok) { sendJson(res, 401, { error: 'Unauthorized' }); return; } @@ -223,7 +286,7 @@ async function handleRequest(req, res) { } if (req.method === 'GET' && pathname === '/api/decision-requests') { - if (!isServerAuthorized(req)) { + if (!resolveOperatorAuth(req).ok) { sendJson(res, 401, { error: 'Unauthorized' }); return; } @@ -234,7 +297,8 @@ async function handleRequest(req, res) { const decideMatch = pathname.match(/^\/api\/decision-requests\/([^/]+)\/decide$/); if (req.method === 'POST' && decideMatch) { - if (!isServerAuthorized(req)) { + const operatorAuth = resolveOperatorAuth(req); + if (!operatorAuth.ok) { sendJson(res, 401, { error: 'Unauthorized' }); return; } @@ -250,6 +314,10 @@ async function handleRequest(req, res) { return; } const requestId = decodeURIComponent(decideMatch[1]); + // 엄격 모드에선 신원을 토큰에서 강제한다 — body decidedBy 위조 방지 (스펙 §2) + if (operatorAuth.mode !== 'open') { + data.decidedBy = operatorAuth.operatorId; + } const result = decideRequest(requestId, data); if (result.error) { sendJson(res, result.status, { error: result.error }); @@ -313,7 +381,7 @@ async function handleRequest(req, res) { } if (req.method === 'GET' && pathname === '/api/history') { - if (!isServerAuthorized(req)) { + if (!resolveOperatorAuth(req).ok) { sendJson(res, 401, { error: 'Unauthorized' }); return; } @@ -371,6 +439,17 @@ wss.on('connection', (socket) => { }; if (SERVER_TOKEN && token === SERVER_TOKEN) { grant('operator'); + socket.operatorId = 'root'; + return; + } + // 개별 운영자 토큰 → 전체 스트림 (스펙 2026-08-04 다중 운영자 §2) + const operator = token ? findOperatorByToken(token) : null; + if (operator) { + socket.isAuthorized = true; + socket.scope = 'operator'; + socket.operatorId = operator.operatorId; + if (authTimer) clearTimeout(authTimer); + socket.send(JSON.stringify({ type: 'WORKFLOW_AUTH_OK', scope: 'operator', operatorId: operator.operatorId })); return; } const actor = token ? findActorByToken(token) : null; @@ -398,6 +477,14 @@ function broadcast(data, { targetActorId = null } = {}) { }); } +function closeOperatorSockets(operatorId) { + wss.clients.forEach((client) => { + if (client.scope === 'operator' && client.operatorId === operatorId) { + client.close(4401, 'OPERATOR_REVOKED'); + } + }); +} + function closeActorSockets(actorId) { wss.clients.forEach((client) => { if (client.scope === 'actor' && client.actorId === actorId) { diff --git a/workflow/server/auth.js b/workflow/server/auth.js index 007dce7..146bb81 100644 --- a/workflow/server/auth.js +++ b/workflow/server/auth.js @@ -2,6 +2,7 @@ // 본체와 달리 grace 경로 없음 — 엄격 per-actor 전용. import { SERVER_TOKEN } from './config.js'; import { findActorByToken } from './actors.js'; +import { findOperatorByToken } from './operators.js'; export function extractBearerToken(headerValue) { if (typeof headerValue !== 'string') return null; @@ -16,6 +17,17 @@ export function isServerAuthorized(req) { return extractBearerToken(req.headers.authorization) === SERVER_TOKEN; } +// 운영자급 인가 (스펙 2026-08-04 다중 운영자 §1): root(서버 토큰) 또는 개별 운영자 토큰. +export function resolveOperatorAuth(req) { + if (!SERVER_TOKEN) return { ok: true, mode: 'open', operatorId: null }; + const token = extractBearerToken(req.headers.authorization); + if (!token) return { ok: false, status: 401, error: 'Unauthorized' }; + if (token === SERVER_TOKEN) return { ok: true, mode: 'root', operatorId: 'root' }; + const operator = findOperatorByToken(token); + if (operator) return { ok: true, mode: 'operator', operatorId: operator.operatorId }; + return { ok: false, status: 401, error: 'Unauthorized' }; +} + export function resolveActorAuth(req) { if (!SERVER_TOKEN) return { ok: true, mode: 'open', actorId: null }; const token = extractBearerToken(req.headers.authorization); diff --git a/workflow/server/config.js b/workflow/server/config.js index aae4514..8099071 100644 --- a/workflow/server/config.js +++ b/workflow/server/config.js @@ -22,6 +22,10 @@ export const HISTORY_STORE_PATH = path.resolve( ROOT_DIR, process.env.MAESTRO_WORKFLOW_HISTORY_STORE_PATH || '.maestro-workflow-history.json', ); +export const OPERATOR_STORE_PATH = path.resolve( + ROOT_DIR, + process.env.MAESTRO_WORKFLOW_OPERATOR_STORE_PATH || '.maestro-workflow-operators.json', +); export const ALLOWED_ORIGINS = ( process.env.MAESTRO_WORKFLOW_ALLOWED_ORIGINS diff --git a/workflow/server/operators.js b/workflow/server/operators.js new file mode 100644 index 0000000..d5bbf90 --- /dev/null +++ b/workflow/server/operators.js @@ -0,0 +1,78 @@ +// 운영자 레지스트리 (스펙 2026-08-04 다중 운영자 §2) — actors.js 미러, heartbeat 없음. +// 토큰은 발급 시 1회만 평문 반환, 레코드에는 sha256 해시만 저장. +import crypto from 'node:crypto'; +import { loadStore, saveStore } from './persist.js'; +import { sanitizeText } from './actors.js'; + +const operatorsById = new Map(); +let storePath = null; + +function generateOperatorToken() { + return crypto.randomBytes(24).toString('hex'); +} + +function hashOperatorToken(token) { + return crypto.createHash('sha256').update(token).digest('hex'); +} + +function persist() { + if (storePath) saveStore(storePath, { items: Array.from(operatorsById.values()) }); +} + +export function initOperatorStore(path) { + storePath = path; + operatorsById.clear(); + const data = loadStore(path); + for (const item of data?.items || []) { + if (item && typeof item.operatorId === 'string' && item.operatorId) { + operatorsById.set(item.operatorId, item); + } + } +} + +// 재등록(upsert) = 무조건 토큰 회전. +export function registerOperator({ operatorId, displayName = '' } = {}) { + const id = sanitizeText(operatorId, 80); + if (!id) return null; + const token = generateOperatorToken(); + const now = new Date().toISOString(); + const existing = operatorsById.get(id) || null; + const operator = { + operatorId: id, + displayName: sanitizeText(displayName, 120), + tokenHash: hashOperatorToken(token), + createdAt: existing?.createdAt || now, + updatedAt: now, + }; + operatorsById.set(id, operator); + persist(); + return { operator, operatorToken: token }; +} + +export function findOperatorByToken(token) { + if (!token) return null; + const tokenHash = hashOperatorToken(token); + return ( + Array.from(operatorsById.values()).find((operator) => operator.tokenHash && operator.tokenHash === tokenHash) + || null + ); +} + +export function revokeOperator(operatorId) { + const operator = operatorsById.get(operatorId); + if (!operator) return null; + operator.tokenHash = null; + operator.updatedAt = new Date().toISOString(); + persist(); + return operator; +} + +export function toPublicOperator(operator) { + if (!operator) return operator; + const { tokenHash, ...publicOperator } = operator; + return publicOperator; +} + +export function listOperators() { + return Array.from(operatorsById.values()).map(toPublicOperator); +} diff --git a/workflow/tests/helpers.mjs b/workflow/tests/helpers.mjs index 9253246..7c927b6 100644 --- a/workflow/tests/helpers.mjs +++ b/workflow/tests/helpers.mjs @@ -41,6 +41,7 @@ export async function startServer({ serverToken = '', tempDir = null, extraEnv = MAESTRO_WORKFLOW_ACTOR_STORE_PATH: resolve(dataDir, 'actors.json'), MAESTRO_WORKFLOW_DECISION_STORE_PATH: resolve(dataDir, 'decisions.json'), MAESTRO_WORKFLOW_HISTORY_STORE_PATH: resolve(dataDir, 'history.json'), + MAESTRO_WORKFLOW_OPERATOR_STORE_PATH: resolve(dataDir, 'operators.json'), ...extraEnv, }, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/workflow/tests/history.test.mjs b/workflow/tests/history.test.mjs index 2e16050..f6bd453 100644 --- a/workflow/tests/history.test.mjs +++ b/workflow/tests/history.test.mjs @@ -50,7 +50,8 @@ test('full flow leaves append-only audit trail that survives restart', async () })).json()).items.find((item) => item.event === 'DECIDED'); assert.equal(decidedEntry.decision, 'approve'); assert.equal(decidedEntry.subjectType, 'spend'); - assert.equal(decidedEntry.decidedBy, 'operator'); + // 다중 운영자 스펙(2026-08-04): 엄격 모드에선 토큰 신원이 강제된다 — 서버 토큰 = root + assert.equal(decidedEntry.decidedBy, 'root'); await first.stop(); diff --git a/workflow/tests/operators.test.mjs b/workflow/tests/operators.test.mjs new file mode 100644 index 0000000..cb6de8d --- /dev/null +++ b/workflow/tests/operators.test.mjs @@ -0,0 +1,161 @@ +// 다중 운영자 (스펙 2026-08-04 §1-2): 운영자 레지스트리·신원 기록·권한 경계. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import WebSocket from 'ws'; +import { startServer, cleanupDataDir, authHeaders } from './helpers.mjs'; + +const ROOT = 'wf-root-secret'; + +async function registerOperator(port, operatorId) { + const res = await fetch(`http://127.0.0.1:${port}/api/operators/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(ROOT) }, + body: JSON.stringify({ operatorId }), + }); + assert.equal(res.status, 200); + return (await res.json()).operatorToken; +} + +async function createPendingRequest(port) { + const actorRes = await fetch(`http://127.0.0.1:${port}/api/actors/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(ROOT) }, + body: JSON.stringify({ actorId: 'agent_x' }), + }); + const actorToken = (await actorRes.json()).actorToken; + const reqRes = await fetch(`http://127.0.0.1:${port}/api/decision-requests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(actorToken) }, + body: JSON.stringify({ subjectType: 'spend', subject: { title: '운영자 테스트' } }), + }); + return (await reqRes.json()).item; +} + +function openSocket(port) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + ws.on('open', () => resolve(ws)); + ws.on('error', reject); + }); +} + +function waitForType(ws, type, timeoutMs = 2000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`no ${type}`)), timeoutMs); + const onMessage = (raw) => { + const data = JSON.parse(raw.toString()); + if (data.type === type) { + clearTimeout(timer); + ws.off('message', onMessage); + resolve(data); + } + }; + ws.on('message', onMessage); + }); +} + +test('운영자 토큰으로 결정하면 decidedBy에 신원이 강제 기록된다 (body 위조 무시)', async () => { + const server = await startServer({ serverToken: ROOT }); + try { + const token = await registerOperator(server.port, 'op_sunjin'); + const request = await createPendingRequest(server.port); + + const listRes = await fetch(`http://127.0.0.1:${server.port}/api/decision-requests?status=pending_decision`, { + headers: authHeaders(token), + }); + assert.equal(listRes.status, 200); + assert.equal((await listRes.json()).items.length, 1); + + const decideRes = await fetch(`http://127.0.0.1:${server.port}/api/decision-requests/${request.requestId}/decide`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(token) }, + body: JSON.stringify({ decision: 'approve', decidedBy: 'forged-identity' }), + }); + assert.equal(decideRes.status, 200); + assert.equal((await decideRes.json()).item.decidedBy, 'op_sunjin'); + + const historyRes = await fetch(`http://127.0.0.1:${server.port}/api/history?limit=10`, { + headers: authHeaders(token), + }); + const decidedEntry = (await historyRes.json()).items.find((entry) => entry.event === 'DECIDED'); + assert.equal(decidedEntry.decidedBy, 'op_sunjin'); + } finally { + await server.stop(); + cleanupDataDir(server.dataDir); + } +}); + +test('관리 라우트(운영자/actor 등록)는 root 전용 — 운영자 토큰은 401', async () => { + const server = await startServer({ serverToken: ROOT }); + try { + const token = await registerOperator(server.port, 'op_a'); + for (const path of ['/api/operators/register', '/api/actors/register']) { + const res = await fetch(`http://127.0.0.1:${server.port}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders(token) }, + body: JSON.stringify({ operatorId: 'x', actorId: 'x' }), + }); + assert.equal(res.status, 401, path); + } + const listRes = await fetch(`http://127.0.0.1:${server.port}/api/operators`, { headers: authHeaders(token) }); + assert.equal(listRes.status, 401); + } finally { + await server.stop(); + cleanupDataDir(server.dataDir); + } +}); + +test('운영자 WS는 전체 스트림을 받고, revoke 시 소켓 4401 + HTTP 401 (root 소켓 유지)', async () => { + const server = await startServer({ serverToken: ROOT }); + const wsOp = await openSocket(server.port); + const wsRoot = await openSocket(server.port); + try { + const token = await registerOperator(server.port, 'op_b'); + wsOp.send(JSON.stringify({ type: 'WORKFLOW_AUTH', token })); + const ok = await waitForType(wsOp, 'WORKFLOW_AUTH_OK'); + assert.equal(ok.scope, 'operator'); + assert.equal(ok.operatorId, 'op_b'); + wsRoot.send(JSON.stringify({ type: 'WORKFLOW_AUTH', token: ROOT })); + await waitForType(wsRoot, 'WORKFLOW_AUTH_OK'); + + const received = waitForType(wsOp, 'WORKFLOW_REQUEST_CREATED'); + await createPendingRequest(server.port); + assert.ok(await received); + + const closed = new Promise((resolve) => wsOp.on('close', (code) => resolve(code))); + const revokeRes = await fetch(`http://127.0.0.1:${server.port}/api/operators/op_b/revoke`, { + method: 'POST', + headers: authHeaders(ROOT), + }); + assert.equal(revokeRes.status, 200); + assert.equal(await closed, 4401); + assert.equal(wsRoot.readyState, WebSocket.OPEN); + + const listRes = await fetch(`http://127.0.0.1:${server.port}/api/decision-requests?status=pending_decision`, { + headers: authHeaders(token), + }); + assert.equal(listRes.status, 401); + } finally { + wsRoot.close(); + await server.stop(); + cleanupDataDir(server.dataDir); + } +}); + +test('운영자 토큰은 재시작 후에도 유효하다 (영속화)', async () => { + const first = await startServer({ serverToken: ROOT }); + let token; + try { + token = await registerOperator(first.port, 'op_persist'); + } finally { + await first.stop(); + } + const second = await startServer({ serverToken: ROOT, tempDir: first.dataDir }); + try { + const res = await fetch(`http://127.0.0.1:${second.port}/api/history?limit=5`, { headers: authHeaders(token) }); + assert.equal(res.status, 200); + } finally { + await second.stop(); + cleanupDataDir(first.dataDir); + } +});