④ 감사 actor 31곳이 'rights.ops' 리터럴이라 누가 판정했는지 답이 없었다. ContextVar 로 요청 범위 운영자를 걸고 스토어는 current_actor() 를 읽는다. 콘솔 좌측 하단 '운영자' 칸 → X-Operator 헤더(한글 percent-encode, 서버에서 제어문자 제거·40자). 이름 없으면 판정이 막힌다. 인증이 아니라 기록 — 권한은 공유 토큰. ⑤ 기준 DB 유형 news_host / scraper_host. 점수는 바꾸지 않고 한 줄 요약에 '(언론·공식 n · 무단 게시처 m)', 증거 호스트 칩 옆에 배지. verify.ps1: ruff clean, 684 passed. 실렌더: 운영자 입력칸 표시 확인(44px). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
272 lines
5 KiB
JavaScript
272 lines
5 KiB
JavaScript
// Part 2 of 15 of the operator console app script.
|
|
//
|
|
// Auth token prompt, fetch wrapper, and bootstrap/refresh of client state.
|
|
//
|
|
// Load order is significant: these are classic (non-module) scripts sharing one
|
|
// global scope, and index.html must keep them in the order listed above/below.
|
|
// Splitting is a pure line-range extraction of the former single app.js.
|
|
|
|
function promptForAuthToken(message = "") {
|
|
|
|
const gate = document.getElementById("auth-gate");
|
|
|
|
const form = document.getElementById("auth-gate-form");
|
|
|
|
const input = document.getElementById("auth-gate-input");
|
|
|
|
const error = document.getElementById("auth-gate-error");
|
|
|
|
if (!gate || !form || !input) {
|
|
|
|
return Promise.resolve("");
|
|
|
|
}
|
|
|
|
// Collapse concurrent 401s onto one prompt so a burst of parallel requests
|
|
// cannot stack multiple dialogs.
|
|
|
|
if (pendingAuthPrompt) {
|
|
|
|
return pendingAuthPrompt;
|
|
|
|
}
|
|
|
|
if (error) {
|
|
|
|
error.hidden = !message;
|
|
|
|
error.textContent = message || "";
|
|
|
|
}
|
|
|
|
gate.hidden = false;
|
|
|
|
input.value = "";
|
|
|
|
input.focus();
|
|
|
|
pendingAuthPrompt = new Promise((resolve) => {
|
|
|
|
const onSubmit = (event) => {
|
|
|
|
event.preventDefault();
|
|
|
|
const value = input.value.trim();
|
|
|
|
if (!value) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
form.removeEventListener("submit", onSubmit);
|
|
|
|
gate.hidden = true;
|
|
|
|
input.value = "";
|
|
|
|
pendingAuthPrompt = null;
|
|
|
|
resolve(value);
|
|
|
|
};
|
|
|
|
form.addEventListener("submit", onSubmit);
|
|
|
|
});
|
|
|
|
return pendingAuthPrompt;
|
|
|
|
}
|
|
|
|
|
|
|
|
function authHeaders(extra) {
|
|
|
|
const headers = { "Content-Type": "application/json", ...(extra || {}) };
|
|
|
|
if (authToken) {
|
|
|
|
headers.Authorization = `Bearer ${authToken}`;
|
|
|
|
}
|
|
|
|
if (operatorName) {
|
|
|
|
// 헤더는 latin-1 이라 한글 이름은 인코딩해 보낸다. 서버가 되돌린다.
|
|
headers["X-Operator"] = encodeURIComponent(operatorName);
|
|
|
|
}
|
|
|
|
return headers;
|
|
|
|
}
|
|
|
|
|
|
|
|
async function apiJson(path, options = {}, allowAuthRetry = true) {
|
|
|
|
const response = await fetch(path, {
|
|
|
|
...options,
|
|
|
|
headers: authHeaders(options.headers),
|
|
|
|
});
|
|
|
|
const text = await response.text();
|
|
|
|
let payload = null;
|
|
|
|
if (text) {
|
|
|
|
try {
|
|
|
|
payload = JSON.parse(text);
|
|
|
|
} catch (error) {
|
|
|
|
payload = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (response.status === 401 && allowAuthRetry) {
|
|
|
|
// Ask for the token, then replay this request once. A second 401 falls
|
|
// through to the normal error path instead of looping.
|
|
|
|
const token = await promptForAuthToken(
|
|
|
|
authToken ? "토큰이 올바르지 않습니다. 다시 입력하세요." : "",
|
|
|
|
);
|
|
|
|
if (token) {
|
|
|
|
authToken = token;
|
|
|
|
return apiJson(path, options, false);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (!response.ok) {
|
|
|
|
const message = (payload && payload.error) || `API 요청 실패: ${response.status}`;
|
|
|
|
throw new Error(message);
|
|
|
|
}
|
|
|
|
return payload;
|
|
|
|
}
|
|
|
|
|
|
|
|
function replaceCollection(target, next) {
|
|
|
|
target.splice(0, target.length, ...(next || []));
|
|
|
|
}
|
|
|
|
|
|
|
|
function clearRuntimeData() {
|
|
|
|
replaceCollection(submissions, []);
|
|
|
|
replaceCollection(providers, []);
|
|
|
|
replaceCollection(knowledgeEntries, []);
|
|
|
|
replaceCollection(collectionCandidates, []);
|
|
|
|
replaceCollection(corrections, []);
|
|
|
|
replaceCollection(auditEvents, []);
|
|
|
|
Object.assign(coverageThresholds, DEFAULT_COVERAGE_THRESHOLDS);
|
|
|
|
Object.keys(searchCoverage).forEach((key) => delete searchCoverage[key]);
|
|
|
|
state.submissionQueue = null;
|
|
|
|
state.selectedCaseId = "";
|
|
|
|
}
|
|
|
|
|
|
|
|
function applyBootstrap(payload) {
|
|
|
|
// Any successful payload proves the API is reachable, so clear a stale error
|
|
// banner here (not only in refreshFromApi) — actions that applyBootstrap
|
|
// directly, e.g. promote/dismiss, otherwise leave the old error visible.
|
|
state.apiError = "";
|
|
|
|
replaceCollection(submissions, payload.submissions);
|
|
|
|
replaceCollection(providers, payload.providers);
|
|
|
|
replaceCollection(knowledgeEntries, payload.knowledgeEntries);
|
|
|
|
state.submissionQueue = payload.submissionQueue || null;
|
|
state.intakeConflicts = Array.isArray(payload.intakeConflicts) ? payload.intakeConflicts : [];
|
|
|
|
Object.assign(
|
|
|
|
coverageThresholds,
|
|
|
|
normalizeCoverageThresholds(payload.coverageThresholds || (payload.searchCoverage && payload.searchCoverage.coverageThresholds)),
|
|
|
|
);
|
|
|
|
Object.keys(searchCoverage).forEach((key) => delete searchCoverage[key]);
|
|
|
|
Object.assign(searchCoverage, payload.searchCoverage || {});
|
|
|
|
replaceCollection(collectionCandidates, payload.collectionCandidates);
|
|
|
|
replaceCollection(corrections, payload.corrections);
|
|
|
|
replaceCollection(auditEvents, payload.auditEvents);
|
|
|
|
replaceCollection(decisionMemoPresets, payload.decisionMemoPresets || []);
|
|
|
|
if (!submissions.some((item) => item.id === state.selectedCaseId)) {
|
|
|
|
state.selectedCaseId = submissions[0]?.id || "";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function refreshFromApi() {
|
|
|
|
const payload = await apiJson("/api/bootstrap");
|
|
|
|
state.apiError = "";
|
|
|
|
applyBootstrap(payload);
|
|
|
|
renderAll();
|
|
|
|
}
|
|
|
|
|
|
|
|
function setImportStatus(message, tone = "idle") {
|
|
|
|
state.importStatus = { message, tone };
|
|
|
|
renderImportStatus();
|
|
|
|
}
|
|
|
|
|