zigmini 0.8.0-beta.6 → 0.9.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +90 -19
- package/README.md +91 -19
- package/assets/agent/gateway-request-rules.md +3 -0
- package/assets/agent/gateway-rules.md +6 -0
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +69 -1
- package/docs/manual.md +67 -1
- package/host/omo-bridge/task-control.ts +4 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
- package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +169 -7
- package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
- package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
- package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
- package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
- package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
- package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
- package/package.json +4 -1
- package/scripts/secret-rules.ts +101 -0
- package/scripts/secret-scan.allow.json +155 -0
- package/scripts/secret-scan.rules.json +30 -0
- package/src/agent-request-rules.ts +82 -0
- package/src/cli/access.ts +31 -34
- package/src/cli/launchd.ts +30 -4
- package/src/cli/main.ts +3 -43
- package/src/cli/messages.ts +160 -0
- package/src/cli/pair.ts +32 -25
- package/src/cli/setup.ts +164 -75
- package/src/decisions.ts +48 -10
- package/src/engine/compat.json +3 -3
- package/src/hub.ts +183 -47
- package/src/main.ts +60 -4
- package/src/notification-history.ts +56 -0
- package/src/omo/client.ts +28 -0
- package/src/protocol.ts +32 -2
- package/src/reply-notifications.ts +144 -0
- package/src/runtime/paths.ts +2 -0
- package/src/security-allow.ts +126 -0
- package/src/security-check.ts +559 -0
- package/src/store.ts +236 -6
- package/src/turn-stats.ts +49 -0
- package/src/work-watch.ts +36 -10
- package/src/work.ts +45 -6
- package/web/DESIGN.md +39 -0
- package/web/app.js +289 -2
- package/web/decisions.d.ts +1 -1
- package/web/decisions.js +6 -0
- package/web/diagram.d.ts +28 -0
- package/web/diagram.js +228 -0
- package/web/index.html +29 -0
- package/web/notifications.d.ts +25 -0
- package/web/notifications.js +118 -0
- package/web/security-inbox.d.ts +35 -0
- package/web/security-inbox.js +237 -0
- package/web/style.css +43 -1
- package/web/turn-stats.d.ts +26 -0
- package/web/turn-stats.js +58 -0
package/web/diagram.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Diagram input is data only. Markdown recognition stays at the actual lexer boundary.
|
|
2
|
+
const BODY_LIMIT = 16_384;
|
|
3
|
+
const ID = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
|
|
4
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
5
|
+
|
|
6
|
+
function keys(value, required, optional = []) {
|
|
7
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
8
|
+
&& required.every((key) => Object.hasOwn(value, key))
|
|
9
|
+
&& Object.keys(value).every((key) => required.includes(key) || optional.includes(key));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function plain(value, limit) {
|
|
13
|
+
return typeof value === "string" && value.trim().length > 0
|
|
14
|
+
&& [...value].length <= limit && !CONTROL.test(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseDiagram(body) {
|
|
18
|
+
if (body.length > BODY_LIMIT) return null;
|
|
19
|
+
let value;
|
|
20
|
+
try {
|
|
21
|
+
value = JSON.parse(body);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (error instanceof SyntaxError) return null;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
if (!keys(value, ["version", "nodes", "edges"], ["title"]) || value.version !== 1) return null;
|
|
27
|
+
if (Object.hasOwn(value, "title") && !plain(value.title, 80)) return null;
|
|
28
|
+
if (!Array.isArray(value.nodes) || value.nodes.length < 1 || value.nodes.length > 16) return null;
|
|
29
|
+
if (!Array.isArray(value.edges) || value.edges.length > 24) return null;
|
|
30
|
+
const ids = new Set();
|
|
31
|
+
for (const node of value.nodes) {
|
|
32
|
+
if (!keys(node, ["id", "label"]) || typeof node.id !== "string" || !ID.test(node.id)
|
|
33
|
+
|| ids.has(node.id) || !plain(node.label, 48)) return null;
|
|
34
|
+
ids.add(node.id);
|
|
35
|
+
}
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const edge of value.edges) {
|
|
38
|
+
if (!keys(edge, ["from", "to"]) || !ids.has(edge.from) || !ids.has(edge.to)
|
|
39
|
+
|| edge.from === edge.to) return null;
|
|
40
|
+
const pair = `${edge.from}:${edge.to}`;
|
|
41
|
+
if (seen.has(pair)) return null;
|
|
42
|
+
seen.add(pair);
|
|
43
|
+
}
|
|
44
|
+
if (diagramLevels(value) === null) return null;
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Kahn traversal uses maximum parent depth, then restores input order within levels.
|
|
49
|
+
export function diagramLevels(diagram) {
|
|
50
|
+
const depths = new Map(diagram.nodes.map((node) => [node.id, 0]));
|
|
51
|
+
const incoming = new Map(diagram.nodes.map((node) => [node.id, 0]));
|
|
52
|
+
const children = new Map(diagram.nodes.map((node) => [node.id, []]));
|
|
53
|
+
for (const edge of diagram.edges) {
|
|
54
|
+
incoming.set(edge.to, incoming.get(edge.to) + 1);
|
|
55
|
+
children.get(edge.from).push(edge.to);
|
|
56
|
+
}
|
|
57
|
+
const queue = diagram.nodes.filter((node) => incoming.get(node.id) === 0).map((node) => node.id);
|
|
58
|
+
let visited = 0;
|
|
59
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
60
|
+
const id = queue[cursor];
|
|
61
|
+
visited++;
|
|
62
|
+
for (const child of children.get(id)) {
|
|
63
|
+
depths.set(child, Math.max(depths.get(child), depths.get(id) + 1));
|
|
64
|
+
incoming.set(child, incoming.get(child) - 1);
|
|
65
|
+
if (incoming.get(child) === 0) queue.push(child);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (visited !== diagram.nodes.length) return null;
|
|
69
|
+
const levels = [];
|
|
70
|
+
for (const node of diagram.nodes) {
|
|
71
|
+
const depth = depths.get(node.id);
|
|
72
|
+
(levels[depth] ??= []).push(node);
|
|
73
|
+
}
|
|
74
|
+
return levels;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function diagramParts(text, markdown) {
|
|
78
|
+
const options = { gfm: true, breaks: true };
|
|
79
|
+
const tokens = markdown.lexer(text, options);
|
|
80
|
+
const parts = [];
|
|
81
|
+
let ordinary = [];
|
|
82
|
+
// The lexer normalizes CRLF. Recover original spans for the body-size limit
|
|
83
|
+
// and source view instead of validating a shorter normalized body.
|
|
84
|
+
const normalized = text.replace(/\r\n?/g, "\n");
|
|
85
|
+
let tokenCursor = 0;
|
|
86
|
+
let originalCursor = 0;
|
|
87
|
+
let normalizedCursor = 0;
|
|
88
|
+
const originalOffset = (target) => {
|
|
89
|
+
while (normalizedCursor < target) {
|
|
90
|
+
if (text[originalCursor] === "\r" && text[originalCursor + 1] === "\n") originalCursor++;
|
|
91
|
+
originalCursor++;
|
|
92
|
+
normalizedCursor++;
|
|
93
|
+
}
|
|
94
|
+
return originalCursor;
|
|
95
|
+
};
|
|
96
|
+
const flush = () => {
|
|
97
|
+
if (ordinary.length === 0) return;
|
|
98
|
+
// The full-message lexer has already resolved inline reference links.
|
|
99
|
+
parts.push({ html: markdown.parser(ordinary, options) });
|
|
100
|
+
ordinary = [];
|
|
101
|
+
};
|
|
102
|
+
for (const token of tokens) {
|
|
103
|
+
const start = normalized.indexOf(token.raw, tokenCursor);
|
|
104
|
+
tokenCursor = start + token.raw.length;
|
|
105
|
+
const from = originalOffset(start);
|
|
106
|
+
const source = text.slice(from, originalOffset(tokenCursor));
|
|
107
|
+
// Do not search rendered <code> elements: raw HTML is not a supported fence.
|
|
108
|
+
const fence = token.type === "code" && token.lang === "zigmini-diagram"
|
|
109
|
+
? /^```zigmini-diagram\r?\n([\s\S]*?)\r?\n```[ \t]*(?:\r?\n|$)$/.exec(source) : null;
|
|
110
|
+
const diagram = fence ? parseDiagram(fence[1]) : null;
|
|
111
|
+
if (!diagram) {
|
|
112
|
+
ordinary.push(token);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
flush();
|
|
116
|
+
parts.push({ diagram, source });
|
|
117
|
+
}
|
|
118
|
+
flush();
|
|
119
|
+
return parts;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function element(doc, tag, className, text) {
|
|
123
|
+
const el = doc.createElement(tag);
|
|
124
|
+
if (className) el.className = className;
|
|
125
|
+
if (text !== undefined) el.textContent = text;
|
|
126
|
+
return el;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createDiagram(doc, diagram, source) {
|
|
130
|
+
const figure = element(doc, "figure", "chat-diagram");
|
|
131
|
+
const title = diagram.title ?? "다이어그램";
|
|
132
|
+
figure.append(element(doc, "figcaption", "diagram-title", title));
|
|
133
|
+
const viewport = element(doc, "div", "diagram-viewport");
|
|
134
|
+
viewport.tabIndex = 0;
|
|
135
|
+
viewport.setAttribute("role", "region");
|
|
136
|
+
viewport.setAttribute("aria-label", `${title}. 다이어그램 영역, 방향키로 스크롤할 수 있습니다.`);
|
|
137
|
+
const stage = element(doc, "div", "diagram-stage");
|
|
138
|
+
const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
139
|
+
svg.setAttribute("class", "diagram-edges");
|
|
140
|
+
svg.setAttribute("aria-hidden", "true");
|
|
141
|
+
svg.setAttribute("focusable", "false");
|
|
142
|
+
stage.append(svg);
|
|
143
|
+
const nodes = new Map();
|
|
144
|
+
const depths = new Map();
|
|
145
|
+
const levels = diagramLevels(diagram);
|
|
146
|
+
for (const [row, level] of levels.entries()) {
|
|
147
|
+
for (const [column, node] of level.entries()) {
|
|
148
|
+
const label = element(doc, "div", "diagram-node", node.label);
|
|
149
|
+
label.style.gridRow = String(row + 1);
|
|
150
|
+
label.style.gridColumn = String(column + 1);
|
|
151
|
+
stage.append(label);
|
|
152
|
+
nodes.set(node.id, label);
|
|
153
|
+
depths.set(node.id, row);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
viewport.append(stage);
|
|
157
|
+
const relationships = element(doc, "ul", "sr-only");
|
|
158
|
+
const labels = new Map(diagram.nodes.map((node) => [node.id, node.label]));
|
|
159
|
+
for (const edge of diagram.edges) {
|
|
160
|
+
relationships.append(element(doc, "li", "", `${labels.get(edge.from)} → ${labels.get(edge.to)}`));
|
|
161
|
+
}
|
|
162
|
+
const details = element(doc, "details", "diagram-source");
|
|
163
|
+
details.append(element(doc, "summary", "", "다이어그램 원문"));
|
|
164
|
+
const pre = element(doc, "pre");
|
|
165
|
+
pre.append(element(doc, "code", "", source));
|
|
166
|
+
details.append(pre);
|
|
167
|
+
figure.append(viewport, relationships, details);
|
|
168
|
+
|
|
169
|
+
// Measure native text boxes, not estimated glyph widths. Text zoom/font changes
|
|
170
|
+
// resize the stage; scrolling does not change the stage-relative coordinates.
|
|
171
|
+
const observer = new ResizeObserver(() => {
|
|
172
|
+
if (!stage.isConnected) {
|
|
173
|
+
observer.disconnect();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const bounds = stage.getBoundingClientRect();
|
|
177
|
+
svg.setAttribute("width", String(bounds.width));
|
|
178
|
+
svg.setAttribute("height", String(bounds.height));
|
|
179
|
+
svg.replaceChildren();
|
|
180
|
+
const boxes = new Map([...nodes].map(([id, node]) => [id, node.getBoundingClientRect()]));
|
|
181
|
+
const rowBottoms = new Map();
|
|
182
|
+
let right = 0;
|
|
183
|
+
for (const [id, box] of boxes) {
|
|
184
|
+
const row = depths.get(id);
|
|
185
|
+
rowBottoms.set(row, Math.max(rowBottoms.get(row) ?? 0, box.bottom - bounds.top));
|
|
186
|
+
right = Math.max(right, box.right - bounds.left);
|
|
187
|
+
}
|
|
188
|
+
const gutter = right + 12;
|
|
189
|
+
for (const edge of diagram.edges) {
|
|
190
|
+
const from = boxes.get(edge.from);
|
|
191
|
+
const to = boxes.get(edge.to);
|
|
192
|
+
const x1 = from.left + from.width / 2 - bounds.left;
|
|
193
|
+
const y1 = from.bottom - bounds.top;
|
|
194
|
+
const x2 = to.left + to.width / 2 - bounds.left;
|
|
195
|
+
const y2 = to.top - bounds.top;
|
|
196
|
+
const path = doc.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
197
|
+
// Long edges travel outside all node boxes, entering/leaving in row gaps.
|
|
198
|
+
const mid = rowBottoms.get(depths.get(edge.from)) + 24;
|
|
199
|
+
const skips = depths.get(edge.to) > depths.get(edge.from) + 1;
|
|
200
|
+
const route = skips ? `V${mid} H${gutter} V${y2 - 24} H${x2}` : `V${mid} H${x2}`;
|
|
201
|
+
path.setAttribute("d", `M${x1},${y1} ${route} V${y2} m-4,-6 l4,6 l4,-6`);
|
|
202
|
+
svg.append(path);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
observer.observe(stage);
|
|
206
|
+
return { element: figure, dispose: () => observer.disconnect() };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const mounted = new WeakMap();
|
|
210
|
+
|
|
211
|
+
export function renderDiagramMarkdown(el, text, markdown, purify) {
|
|
212
|
+
const previous = mounted.get(el) ?? [];
|
|
213
|
+
const open = previous.map(({ element: figure }) => figure.querySelector("details").open);
|
|
214
|
+
for (const view of previous) view.dispose();
|
|
215
|
+
el.replaceChildren();
|
|
216
|
+
const views = [];
|
|
217
|
+
for (const part of diagramParts(text, markdown)) {
|
|
218
|
+
if ("html" in part) {
|
|
219
|
+
el.insertAdjacentHTML("beforeend", purify.sanitize(part.html));
|
|
220
|
+
} else {
|
|
221
|
+
const view = createDiagram(el.ownerDocument, part.diagram, part.source);
|
|
222
|
+
view.element.querySelector("details").open = open[views.length] ?? false;
|
|
223
|
+
el.append(view.element);
|
|
224
|
+
views.push(view);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
mounted.set(el, views);
|
|
228
|
+
}
|
package/web/index.html
CHANGED
|
@@ -177,6 +177,35 @@
|
|
|
177
177
|
<p class="enroll-message" id="apk-message" role="status" aria-live="polite"></p>
|
|
178
178
|
<div class="enroll-actions"><button type="button" class="text-btn" id="apk-renew" hidden>새로 만들기</button></div>
|
|
179
179
|
</section>
|
|
180
|
+
<section class="settings-section security-check" id="security-check" aria-labelledby="security-check-title">
|
|
181
|
+
<div class="section-label"><span id="security-check-title">보안검사</span></div>
|
|
182
|
+
<p class="update-line">보낸 메시지에 비밀키, 토큰, 비밀번호, 홈 폴더 경로, 내부 IP 같은 값이 있으면 엔진에 보내지 않고 보류한 뒤, 값을 보여 주고 보낼지 묻습니다.</p>
|
|
183
|
+
<div class="update-buttons"><button type="button" class="text-btn" id="toggle-security" aria-pressed="true">보안검사 켜짐</button></div>
|
|
184
|
+
<div class="section-label">기억한 정확한 값</div>
|
|
185
|
+
<p class="update-note">이 설치에서 승인한 값만 다음 검사부터 허용합니다. 제거·전체 삭제는 실행 시점의 값을 지웁니다. 다음 검사에서 다시 보류되며 이미 시작한 전송은 취소하지 않습니다. 저장소·릴리스 보안검사는 바뀌지 않습니다.</p>
|
|
186
|
+
<div class="update-buttons">
|
|
187
|
+
<button type="button" class="text-btn" id="security-allowed-refresh">목록 확인 / 다시 시도</button>
|
|
188
|
+
<button type="button" class="text-btn" id="security-allowed-clear" disabled>전체 삭제</button>
|
|
189
|
+
</div>
|
|
190
|
+
<p class="enroll-message" id="security-allowed-status" role="status" aria-live="polite"></p>
|
|
191
|
+
<ul class="security-allowed-list" id="security-allowed-list" aria-label="기억한 정확한 값"></ul>
|
|
192
|
+
<div class="inbox-dismiss mode-cancel" id="security-allowed-confirm" hidden>
|
|
193
|
+
<p class="inbox-dismiss-text">선택한 값 또는 실행 시점의 전체 값을 제거합니다. 다음 검사부터 다시 보류되며 이미 시작한 전송은 취소되지 않습니다.</p>
|
|
194
|
+
<button type="button" class="text-btn danger" id="security-allowed-remove">제거 확인</button>
|
|
195
|
+
<button type="button" class="text-btn" id="security-allowed-keep">그대로 두기</button>
|
|
196
|
+
</div>
|
|
197
|
+
</section>
|
|
198
|
+
<section class="settings-section" id="notification-settings" aria-labelledby="notification-title">
|
|
199
|
+
<div class="section-label"><label id="notification-title" for="notification-mode">알림</label></div>
|
|
200
|
+
<div class="access-form">
|
|
201
|
+
<select class="access-input" id="notification-mode" aria-describedby="notification-description notification-status" disabled>
|
|
202
|
+
<option value="inbox-only" selected>확인함 알림만</option>
|
|
203
|
+
<option value="all-replies">모든 답장 알림</option>
|
|
204
|
+
</select>
|
|
205
|
+
</div>
|
|
206
|
+
<p class="update-note" id="notification-description">모든 답장 알림은 확인함 알림과 메인·작업의 완료된 답장 알림을 포함합니다. 이 게이트웨이에 연결된 모든 기기에 적용됩니다.</p>
|
|
207
|
+
<p class="enroll-message" id="notification-status" role="status" aria-live="polite"></p>
|
|
208
|
+
</section>
|
|
180
209
|
<section class="settings-section gc" id="gc" aria-labelledby="gc-title">
|
|
181
210
|
<div class="section-label"><span id="gc-title">정리</span><span class="gc-head"><span class="gc-summary" id="gc-summary" role="status" aria-live="polite"></span><label class="gc-all" id="gc-all-label" hidden><input type="checkbox" id="gc-all">전체 선택</label></span></div>
|
|
182
211
|
<ul class="gc-list" id="gc-list" aria-label="정리 후보"></ul>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type NotificationMode = "inbox-only" | "all-replies";
|
|
2
|
+
export const NOTIFICATION_SETTINGS_PATH: string;
|
|
3
|
+
export interface NotificationSettingsView {
|
|
4
|
+
readonly mode: NotificationMode;
|
|
5
|
+
readonly ready: boolean;
|
|
6
|
+
readonly online: boolean;
|
|
7
|
+
readonly busy: boolean;
|
|
8
|
+
readonly error: string;
|
|
9
|
+
}
|
|
10
|
+
export interface NotificationSettingsController {
|
|
11
|
+
readonly view: () => NotificationSettingsView;
|
|
12
|
+
readonly read: () => Promise<void>;
|
|
13
|
+
readonly set: (mode: NotificationMode) => Promise<void>;
|
|
14
|
+
readonly connect: () => Promise<void>;
|
|
15
|
+
readonly disconnect: () => void;
|
|
16
|
+
readonly updated: (body: unknown) => void;
|
|
17
|
+
}
|
|
18
|
+
export type SettingsFetch = (path: string, init: RequestInit) => Promise<Pick<Response, "ok" | "json">>;
|
|
19
|
+
export function notificationMode(body: unknown): NotificationMode | null;
|
|
20
|
+
export function createNotificationSettings(fetcher: SettingsFetch, render: (view: NotificationSettingsView) => void): NotificationSettingsController;
|
|
21
|
+
export interface NotificationSettingsElements {
|
|
22
|
+
readonly choice: { value: string; disabled: boolean; onchange: (() => Promise<void>) | null };
|
|
23
|
+
readonly status: { textContent: string | null; classList: Pick<DOMTokenList, "toggle"> };
|
|
24
|
+
}
|
|
25
|
+
export function setupNotificationSettings(elements: NotificationSettingsElements, fetcher?: SettingsFetch): NotificationSettingsController;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export const NOTIFICATION_SETTINGS_PATH = "/api/settings/notifications";
|
|
2
|
+
|
|
3
|
+
/** Parse gateway data without treating a failed or malformed response as a default. */
|
|
4
|
+
export function notificationMode(body) {
|
|
5
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) return null;
|
|
6
|
+
if (body.mode === "inbox-only" || body.mode === "all-replies") return body.mode;
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Confirmed gateway state only. Requests belong to one connection epoch; writes run in order. */
|
|
11
|
+
export function createNotificationSettings(fetcher, render) {
|
|
12
|
+
let mode = "inbox-only";
|
|
13
|
+
let ready = false;
|
|
14
|
+
let online = false;
|
|
15
|
+
let error = "";
|
|
16
|
+
let epoch = 0;
|
|
17
|
+
let generation = 0;
|
|
18
|
+
let pending = 0;
|
|
19
|
+
let tail = Promise.resolve();
|
|
20
|
+
const view = () => ({ mode, ready, online, busy: pending > 0, error });
|
|
21
|
+
const paint = () => render(view());
|
|
22
|
+
const confirm = (value) => {
|
|
23
|
+
mode = value;
|
|
24
|
+
ready = true;
|
|
25
|
+
error = "";
|
|
26
|
+
paint();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
async function read() {
|
|
30
|
+
if (!online) return;
|
|
31
|
+
const connection = epoch;
|
|
32
|
+
const request = ++generation;
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetcher(NOTIFICATION_SETTINGS_PATH, { cache: "no-store" });
|
|
35
|
+
if (!response.ok) throw new Error("notification_read_failed");
|
|
36
|
+
const value = notificationMode(await response.json());
|
|
37
|
+
if (value === null) throw new Error("notification_response_invalid");
|
|
38
|
+
if (connection === epoch && request === generation) confirm(value);
|
|
39
|
+
} catch {
|
|
40
|
+
if (connection === epoch && request === generation) {
|
|
41
|
+
error = "알림 설정을 불러오지 못했습니다. 다시 연결하면 재시도합니다.";
|
|
42
|
+
paint();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function set(value) {
|
|
48
|
+
if (!online || !ready || notificationMode({ mode: value }) === null) return Promise.resolve();
|
|
49
|
+
const connection = epoch;
|
|
50
|
+
generation += 1;
|
|
51
|
+
pending += 1;
|
|
52
|
+
paint();
|
|
53
|
+
tail = tail.then(async () => {
|
|
54
|
+
try {
|
|
55
|
+
if (connection !== epoch) return;
|
|
56
|
+
const request = ++generation;
|
|
57
|
+
const response = await fetcher(NOTIFICATION_SETTINGS_PATH, {
|
|
58
|
+
method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ mode: value }),
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok) throw new Error("notification_save_failed");
|
|
61
|
+
const saved = notificationMode(await response.json());
|
|
62
|
+
if (saved === null) throw new Error("notification_response_invalid");
|
|
63
|
+
if (connection !== epoch) return;
|
|
64
|
+
if (request === generation) confirm(saved);
|
|
65
|
+
else await read(); // A newer event/read may describe another client's write.
|
|
66
|
+
} catch {
|
|
67
|
+
if (connection === epoch) {
|
|
68
|
+
error = "알림 설정을 저장하지 못했습니다. 확인된 설정을 유지합니다.";
|
|
69
|
+
paint();
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
pending -= 1;
|
|
73
|
+
paint();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return tail;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
paint();
|
|
80
|
+
return {
|
|
81
|
+
view,
|
|
82
|
+
read,
|
|
83
|
+
set,
|
|
84
|
+
connect() {
|
|
85
|
+
epoch += 1;
|
|
86
|
+
generation += 1;
|
|
87
|
+
online = true;
|
|
88
|
+
ready = false;
|
|
89
|
+
error = "";
|
|
90
|
+
paint();
|
|
91
|
+
return read();
|
|
92
|
+
},
|
|
93
|
+
disconnect() {
|
|
94
|
+
epoch += 1;
|
|
95
|
+
generation += 1;
|
|
96
|
+
online = false;
|
|
97
|
+
paint();
|
|
98
|
+
},
|
|
99
|
+
updated(body) {
|
|
100
|
+
const value = notificationMode(body);
|
|
101
|
+
if (!online || value === null) return;
|
|
102
|
+
generation += 1;
|
|
103
|
+
confirm(value);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Reuse the settings dialog's native select and live status region. */
|
|
109
|
+
export function setupNotificationSettings({ choice, status }, fetcher = fetch) {
|
|
110
|
+
const controller = createNotificationSettings(fetcher, (view) => {
|
|
111
|
+
choice.value = view.mode;
|
|
112
|
+
choice.disabled = !view.online || !view.ready || view.busy;
|
|
113
|
+
status.textContent = view.error || (view.busy ? "저장 중…" : !view.online ? "게이트웨이 연결을 기다립니다." : !view.ready ? "알림 설정 확인 중…" : "");
|
|
114
|
+
status.classList.toggle("error", Boolean(view.error));
|
|
115
|
+
});
|
|
116
|
+
choice.onchange = () => controller.set(choice.value);
|
|
117
|
+
return controller;
|
|
118
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { HeldState, AllowedValue } from "../src/security-allow.ts";
|
|
2
|
+
import type { HeldMessage } from "../src/security-check.ts";
|
|
3
|
+
|
|
4
|
+
export const HELD_PATH: "/api/security-check/held";
|
|
5
|
+
export const ALLOWED_PATH: "/api/settings/security-check/allowed-values";
|
|
6
|
+
export type HeldDetail = HeldState & Partial<Pick<HeldMessage, "occurrences" | "draft">>;
|
|
7
|
+
export interface Entry {
|
|
8
|
+
held: HeldDetail;
|
|
9
|
+
busy: boolean;
|
|
10
|
+
uncertain: boolean;
|
|
11
|
+
draft: string | undefined;
|
|
12
|
+
restored: boolean;
|
|
13
|
+
request?: {
|
|
14
|
+
readonly send: boolean; readonly remember?: true;
|
|
15
|
+
readonly expectedRevision: number; readonly submissionId: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export type Fetcher = (path: string, init: RequestInit) => Promise<Pick<Response, "ok" | "status" | "json">>;
|
|
19
|
+
export function completeDetail(held: HeldDetail): boolean;
|
|
20
|
+
export function canRestoreHeld(held: HeldState, composer: { conv: string; text: string; images: number; references: number }): boolean;
|
|
21
|
+
export function heldView(entry: Entry, online: boolean, supported: boolean): {
|
|
22
|
+
status: HeldState["status"]; remembered: boolean | null; approve: boolean; cancel: boolean; check: boolean; hide: boolean;
|
|
23
|
+
};
|
|
24
|
+
export function createSecurityInbox(fetcher: Fetcher, changed?: () => void): {
|
|
25
|
+
entries: Map<string, Entry>; hidden: Set<string>;
|
|
26
|
+
merge(held: HeldDetail, confirmed?: boolean): boolean;
|
|
27
|
+
refresh(): Promise<void>; detail(id: string): Promise<void>; decide(id: string, send: boolean): Promise<void>;
|
|
28
|
+
view(id: string): ReturnType<typeof heldView>;
|
|
29
|
+
supported(value: boolean): void; connect(): Promise<void>; disconnect(): void; hide(id: string): boolean;
|
|
30
|
+
};
|
|
31
|
+
export function createAllowedValues(fetcher: Fetcher, changed?: () => void): {
|
|
32
|
+
state: { revision: number; values: AllowedValue[]; online: boolean; busy: boolean; loading: boolean; error: string; ready: boolean };
|
|
33
|
+
refresh(): Promise<void>; connect(): Promise<void>; disconnect(): void; updated(revision: number): void;
|
|
34
|
+
remove(id?: string | null): Promise<void>;
|
|
35
|
+
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Installation-local, memory-only client state. Never persist held details or drafts.
|
|
2
|
+
export const HELD_PATH = "/api/security-check/held";
|
|
3
|
+
export const ALLOWED_PATH = "/api/settings/security-check/allowed-values";
|
|
4
|
+
const TERMINAL = new Set(["sent", "cancelled", "failed", "unconfirmed"]);
|
|
5
|
+
const STATUSES = new Set(["pending", "processing", ...TERMINAL]);
|
|
6
|
+
|
|
7
|
+
export function completeDetail(held) {
|
|
8
|
+
return Array.isArray(held?.occurrences) && held.occurrences.length > 0 &&
|
|
9
|
+
held.occurrences.every((o) => typeof o.canonicalValue === "string" && o.canonicalValue.length > 0);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function canRestoreHeld(held, composer) {
|
|
13
|
+
return held.source === "message" && (held.status === "cancelled" || held.status === "failed") &&
|
|
14
|
+
held.conv === composer.conv && composer.text === "" && composer.images === 0 && composer.references === 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function heldView(entry, online, supported) {
|
|
18
|
+
const held = entry.held;
|
|
19
|
+
const status = entry.uncertain ? "unconfirmed" : entry.busy && !TERMINAL.has(held.status) ? "processing" : held.status;
|
|
20
|
+
return {
|
|
21
|
+
status, remembered: entry.uncertain ? null : held.remembered,
|
|
22
|
+
approve: online && supported && !entry.busy && !entry.uncertain && held.status === "pending" && completeDetail(held) && (!entry.request || entry.request.send),
|
|
23
|
+
cancel: online && !entry.busy && !entry.uncertain && held.status === "pending" && (!entry.request || !entry.request.send),
|
|
24
|
+
check: online && !entry.busy && (entry.uncertain || held.status === "processing" || held.status === "unconfirmed"),
|
|
25
|
+
hide: !entry.busy && !entry.uncertain && TERMINAL.has(held.status),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One controller for both surfaces; changed is synchronous, including the pre-await lock. */
|
|
30
|
+
export function createSecurityInbox(fetcher, changed = () => {}) {
|
|
31
|
+
const entries = new Map();
|
|
32
|
+
const hidden = new Set();
|
|
33
|
+
const loads = new Map();
|
|
34
|
+
let generation = 0;
|
|
35
|
+
let online = false;
|
|
36
|
+
let supported = false;
|
|
37
|
+
|
|
38
|
+
function merge(held, confirmed = false) {
|
|
39
|
+
if (!held || typeof held.id !== "string" || !Number.isSafeInteger(held.revision) || held.revision < 1 ||
|
|
40
|
+
!STATUSES.has(held.status) || typeof held.remembered !== "boolean") return false;
|
|
41
|
+
const previous = entries.get(held.id);
|
|
42
|
+
if (previous && held.revision < previous.held.revision) return false;
|
|
43
|
+
if (previous && held.revision === previous.held.revision) {
|
|
44
|
+
if (!completeDetail(previous.held) && completeDetail(held) && !TERMINAL.has(previous.held.status)) {
|
|
45
|
+
previous.held = { ...previous.held, occurrences: held.occurrences, draft: held.draft };
|
|
46
|
+
}
|
|
47
|
+
if (previous.draft === undefined && typeof held.draft === "string" && !TERMINAL.has(previous.held.status)) previous.draft = held.draft;
|
|
48
|
+
if (confirmed) previous.uncertain = false;
|
|
49
|
+
} else {
|
|
50
|
+
const detail = previous && !TERMINAL.has(held.status) ? previous.held : {};
|
|
51
|
+
const next = {
|
|
52
|
+
held: { ...detail, ...held }, busy: previous?.busy ?? false, uncertain: false,
|
|
53
|
+
draft: held.draft ?? previous?.draft, restored: previous?.restored ?? false,
|
|
54
|
+
};
|
|
55
|
+
// Keep the entry identity held by an in-flight request's finally block.
|
|
56
|
+
if (previous) Object.assign(previous, next);
|
|
57
|
+
else entries.set(held.id, next);
|
|
58
|
+
}
|
|
59
|
+
changed();
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function detail(id) {
|
|
64
|
+
if (!online || loads.has(id)) return;
|
|
65
|
+
const epoch = generation;
|
|
66
|
+
loads.set(id, epoch);
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetcher(`${HELD_PATH}/${encodeURIComponent(id)}`, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
|
|
69
|
+
const body = await response.json();
|
|
70
|
+
if (epoch !== generation) return;
|
|
71
|
+
if (!response.ok || body?.id !== id || !merge(body, true)) {
|
|
72
|
+
const entry = entries.get(id);
|
|
73
|
+
if (entry && !TERMINAL.has(entry.held.status)) entry.uncertain = true;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
if (epoch !== generation) return;
|
|
77
|
+
const entry = entries.get(id);
|
|
78
|
+
if (entry && !TERMINAL.has(entry.held.status)) entry.uncertain = true;
|
|
79
|
+
} finally {
|
|
80
|
+
if (epoch === generation) {
|
|
81
|
+
loads.delete(id);
|
|
82
|
+
changed();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function refresh() {
|
|
88
|
+
const epoch = generation;
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetcher(HELD_PATH, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
|
|
91
|
+
const body = await response.json();
|
|
92
|
+
if (epoch !== generation || !response.ok) return;
|
|
93
|
+
const snapshots = body.held ?? [];
|
|
94
|
+
const ids = new Set(snapshots.map((h) => h.id));
|
|
95
|
+
const missing = [];
|
|
96
|
+
for (const entry of entries.values()) {
|
|
97
|
+
if (!ids.has(entry.held.id) && !TERMINAL.has(entry.held.status)) {
|
|
98
|
+
entry.uncertain = true;
|
|
99
|
+
missing.push(entry.held.id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const held of snapshots) merge(held);
|
|
103
|
+
if (missing.length > 0) changed();
|
|
104
|
+
// Store-backed gateways return terminal receipts through the dedicated detail endpoint.
|
|
105
|
+
await Promise.all(missing.map((id) => detail(id)));
|
|
106
|
+
} catch {
|
|
107
|
+
// A missing response never proves non-delivery.
|
|
108
|
+
} finally {
|
|
109
|
+
if (epoch === generation) changed();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function decide(id, send) {
|
|
114
|
+
const entry = entries.get(id);
|
|
115
|
+
if (!entry) return;
|
|
116
|
+
const view = heldView(entry, online, supported);
|
|
117
|
+
if (!(send ? view.approve : view.cancel)) return;
|
|
118
|
+
entry.request ??= {
|
|
119
|
+
...(send ? { send: true, remember: true } : { send: false }),
|
|
120
|
+
expectedRevision: entry.held.revision, submissionId: crypto.randomUUID(),
|
|
121
|
+
};
|
|
122
|
+
entry.busy = true;
|
|
123
|
+
const epoch = generation;
|
|
124
|
+
changed();
|
|
125
|
+
try {
|
|
126
|
+
const response = await fetcher(`${HELD_PATH}/${encodeURIComponent(id)}`, {
|
|
127
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
128
|
+
body: JSON.stringify(entry.request),
|
|
129
|
+
signal: AbortSignal.timeout(30_000),
|
|
130
|
+
});
|
|
131
|
+
const body = await response.json();
|
|
132
|
+
if (epoch !== generation) return;
|
|
133
|
+
const snapshot = body?.held ?? body;
|
|
134
|
+
const accepted = snapshot?.id === id && Number.isSafeInteger(snapshot.revision) && merge(snapshot);
|
|
135
|
+
if ((!accepted || response.status >= 500) && !TERMINAL.has(entry.held.status)) entry.uncertain = true;
|
|
136
|
+
} catch {
|
|
137
|
+
if (epoch === generation && !TERMINAL.has(entry.held.status)) entry.uncertain = true;
|
|
138
|
+
} finally {
|
|
139
|
+
if (epoch === generation) {
|
|
140
|
+
entry.busy = false;
|
|
141
|
+
changed();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
entries, hidden, merge, refresh, detail, decide,
|
|
148
|
+
view: (id) => heldView(entries.get(id), online, supported),
|
|
149
|
+
supported: (value) => { supported = value === true; changed(); },
|
|
150
|
+
connect: () => { generation++; online = true; loads.clear(); return refresh(); },
|
|
151
|
+
disconnect: () => {
|
|
152
|
+
generation++;
|
|
153
|
+
online = false;
|
|
154
|
+
supported = false;
|
|
155
|
+
loads.clear();
|
|
156
|
+
for (const entry of entries.values()) {
|
|
157
|
+
if (!TERMINAL.has(entry.held.status) && (entry.busy || entry.held.status === "processing")) entry.uncertain = true;
|
|
158
|
+
entry.busy = false;
|
|
159
|
+
}
|
|
160
|
+
changed();
|
|
161
|
+
},
|
|
162
|
+
hide: (id) => {
|
|
163
|
+
const entry = entries.get(id);
|
|
164
|
+
if (!entry || !heldView(entry, online, supported).hide) return false;
|
|
165
|
+
hidden.add(id);
|
|
166
|
+
changed();
|
|
167
|
+
return true;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function createAllowedValues(fetcher, changed = () => {}) {
|
|
173
|
+
const state = { revision: -1, values: [], online: false, busy: false, loading: false, error: "", ready: false };
|
|
174
|
+
let generation = 0;
|
|
175
|
+
let read = 0;
|
|
176
|
+
let wanted = -1;
|
|
177
|
+
async function refresh() {
|
|
178
|
+
if (!state.online) return;
|
|
179
|
+
const epoch = generation;
|
|
180
|
+
const request = ++read;
|
|
181
|
+
state.loading = true;
|
|
182
|
+
state.error = "";
|
|
183
|
+
changed();
|
|
184
|
+
try {
|
|
185
|
+
const response = await fetcher(ALLOWED_PATH, { cache: "no-store", signal: AbortSignal.timeout(30_000) });
|
|
186
|
+
const body = await response.json();
|
|
187
|
+
if (epoch !== generation || request !== read) return;
|
|
188
|
+
if (!response.ok || !Number.isSafeInteger(body.revision) || !Array.isArray(body.values)) throw new Error("list_unavailable");
|
|
189
|
+
if (body.revision >= state.revision && body.revision >= wanted) {
|
|
190
|
+
state.revision = body.revision;
|
|
191
|
+
state.values = body.values;
|
|
192
|
+
state.ready = true;
|
|
193
|
+
} else state.error = "목록이 변경됐습니다. 다시 확인하세요.";
|
|
194
|
+
} catch {
|
|
195
|
+
if (epoch === generation && request === read) state.error = "허용 값을 확인하지 못했습니다. 다시 시도하세요.";
|
|
196
|
+
} finally {
|
|
197
|
+
if (epoch === generation && request === read) {
|
|
198
|
+
state.loading = false;
|
|
199
|
+
changed();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
state, refresh,
|
|
205
|
+
connect: () => { generation++; state.online = true; return refresh(); },
|
|
206
|
+
disconnect: () => {
|
|
207
|
+
generation++; state.online = false; state.busy = false; state.loading = false; state.ready = false; changed();
|
|
208
|
+
},
|
|
209
|
+
updated: (revision) => {
|
|
210
|
+
if (revision <= Math.max(wanted, state.revision)) return;
|
|
211
|
+
wanted = revision;
|
|
212
|
+
void refresh();
|
|
213
|
+
},
|
|
214
|
+
remove: async (id = null) => {
|
|
215
|
+
if (!state.online || state.busy || state.loading || !state.ready || state.error) return;
|
|
216
|
+
state.busy = true;
|
|
217
|
+
state.error = "";
|
|
218
|
+
const epoch = generation;
|
|
219
|
+
changed();
|
|
220
|
+
try {
|
|
221
|
+
const response = await fetcher(id === null ? ALLOWED_PATH : `${ALLOWED_PATH}/${encodeURIComponent(id)}`, {
|
|
222
|
+
method: "DELETE", headers: { "content-type": "application/json" },
|
|
223
|
+
body: JSON.stringify({ expectedRevision: state.revision }), signal: AbortSignal.timeout(30_000),
|
|
224
|
+
});
|
|
225
|
+
const body = await response.json();
|
|
226
|
+
if (epoch !== generation) return;
|
|
227
|
+
if (!response.ok) throw new Error("delete_unconfirmed");
|
|
228
|
+
wanted = Math.max(wanted, body.revision);
|
|
229
|
+
await refresh();
|
|
230
|
+
} catch {
|
|
231
|
+
if (epoch === generation) state.error = "삭제 결과를 확인하지 못했습니다. 목록을 다시 확인하세요. 삭제를 자동 반복하지 않습니다.";
|
|
232
|
+
} finally {
|
|
233
|
+
if (epoch === generation) { state.busy = false; changed(); }
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|