cordenar-mcp 0.1.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.
@@ -0,0 +1,96 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Cordenar</title>
7
+ <link rel="stylesheet" href="/style.css">
8
+ <link rel="icon" href="/logo.svg" type="image/svg+xml">
9
+ <script>if(localStorage.getItem('cordenar-theme')==='light'){document.documentElement.setAttribute('data-theme','light')}</script>
10
+ </head>
11
+ <body>
12
+ <div id="app" role="application" aria-label="Cordenar">
13
+ <header role="banner">
14
+ <div class="logo-title">
15
+ <svg xmlns="http://www.w3.org/2000/svg" height="28" viewBox="0 -960 960 960" width="28" fill="currentColor" aria-hidden="true"><path d="M480-60q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-73q-11-4-21-9.5T401-389l-63 37q1 5 1.5 10.5t.5 11.5q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-50 35-85t85-35q23 0 43 7.5t36 21.5l62-36q-1-5-1.5-11t-.5-12q0-6 .5-11.5T361-502l-62-37q-16 14-36 21.5t-43 7.5q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 6-.5 12t-1.5 11l63 36q8-8 18-13t21-9v-73q-35-12-57.5-43.5T360-780q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 70.5T520-666v73q11 4 20.5 9.5T558-570l64-38q-1-5-1.5-10.5T620-630q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-23 0-42.5-7.5T662-539l-65 38q1 5 1.5 10.5t.5 10.5q0 5-.5 11t-1.5 11l65 37q16-14 35.5-21.5T740-450q50 0 85 35t35 85q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-6 .5-11.5T622-352l-64-37q-8 8-17.5 13t-20.5 9v74q35 12 57.5 43t22.5 70q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T520-180q0-17-11.5-28.5T480-220q-17 0-28.5 11.5T440-180q0 17 11.5 28.5T480-140ZM220-290q17 0 28.5-11.5T260-330q0-17-11.5-28.5T220-370q-17 0-28.5 11.5T180-330q0 17 11.5 28.5T220-290Zm520 0q17 0 28.5-11.5T780-330q0-17-11.5-28.5T740-370q-17 0-28.5 11.5T700-330q0 17 11.5 28.5T740-290ZM480-440q17 0 28.5-11.5T520-480q0-17-11.5-28.5T480-520q-17 0-28.5 11.5T440-480q0 17 11.5 28.5T480-440ZM220-590q17 0 28.5-11.5T260-630q0-17-11.5-28.5T220-670q-17 0-28.5 11.5T180-630q0 17 11.5 28.5T220-590Zm520 0q17 0 28.5-11.5T780-630q0-17-11.5-28.5T740-670q-17 0-28.5 11.5T700-630q0 17 11.5 28.5T740-590ZM480-740q17 0 28.5-11.5T520-780q0-17-11.5-28.5T480-820q-17 0-28.5 11.5T440-780q0 17 11.5 28.5T480-740Z"/></svg>
16
+ <h1>Cordenar</h1>
17
+ </div>
18
+ <div class="header-right">
19
+ <span id="status-dot" class="status-dot" title="Status"></span>
20
+ <button id="theme-btn" class="theme-btn" aria-label="Toggle theme"><svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="currentColor" aria-hidden="true"><path d="M337.5-463Q311-498 289-537q-5 14-6.5 28.5T281-480q0 83 58 141t141 58q14 0 28.5-2t28.5-6q-39-22-74-48.5T396-396q-32-32-58.5-67ZM567-364.5Q630-328 702-308q-40 51-98 79.5T481-200q-117 0-198.5-81.5T201-480q0-65 28.5-123t79.5-98q20 72 56.5 135T453-452q51 51 114 87.5ZM743-380q-20-5-39.5-11T665-405q8-18 11.5-36.5T680-480q0-83-58.5-141.5T480-680q-20 0-38.5 3.5T405-665q-8-19-13.5-38T381-742q24-9 49-13.5t51-4.5q117 0 198.5 81.5T761-480q0 26-4.5 51T743-380ZM440-840v-120h80v120h-80Zm0 840v-120h80V0h-80Zm323-706-57-57 85-84 57 56-85 85ZM169-113l-57-56 85-85 57 57-85 84Zm671-327v-80h120v80H840ZM0-440v-80h120v80H0Zm791 328-85-85 57-57 84 85-56 57ZM197-706l-84-85 56-57 85 85-57 57Zm199 310Z"></path></svg></button>
21
+ </div>
22
+ </header>
23
+
24
+ <div class="filters" role="search" aria-label="Filter synapses">
25
+ <div class="filter-row">
26
+ <div class="field">
27
+ <label for="source">Source</label>
28
+ <select id="source" aria-label="Filter by source">
29
+ <option value="">All sources</option>
30
+ <option value="hemisphere">Hemisphere</option>
31
+ <option value="compend">Compend</option>
32
+ </select>
33
+ </div>
34
+ <div class="field">
35
+ <label for="status">Status</label>
36
+ <select id="status" aria-label="Filter by status">
37
+ <option value="">All statuses</option>
38
+ <option value="unshared">Unshared</option>
39
+ <option value="shared">Shared (active)</option>
40
+ <option value="pulled">Pulled</option>
41
+ </select>
42
+ </div>
43
+ <div class="field">
44
+ <label for="search">Search</label>
45
+ <input type="search" id="search" placeholder="Search synapses..." aria-label="Search synapses">
46
+ </div>
47
+ <div class="field" style="justify-content: flex-end">
48
+ <button id="sync-btn" class="btn">
49
+ <svg xmlns="http://www.w3.org/2000/svg" height="18px" viewBox="0 -960 960 960" width="18px" fill="currentColor" aria-hidden="true">
50
+ <path d="M160-160v-80h110l-16-14q-52-46-73-105t-21-119q0-111 66.5-197.5T400-790v84q-72 26-116 88.5T240-478q0 45 16 86.5t49 73.5l16 16v-98h80v240H160Zm400-10v-84q72-26 116-88.5T720-482q0-45-16-86.5T655-642l-16-16v98h-80v-240h240v80H690l16 14q52 46 73 105t21 119q0 111-66.5 197.5T560-170Z"/>
51
+ </svg>
52
+ Sync
53
+ </button>
54
+ </div>
55
+ </div>
56
+ </div>
57
+
58
+ <div id="stats" class="stats"></div>
59
+
60
+ <div class="table-wrap">
61
+ <table>
62
+ <thead>
63
+ <tr>
64
+ <th style="width:40px">ID</th>
65
+ <th style="width:70px">Source</th>
66
+ <th style="width:100px">Type</th>
67
+ <th>Preview</th>
68
+ <th style="width:90px">Status</th>
69
+ <th style="width:60px">Time</th>
70
+ <th style="width:40px"></th>
71
+ </tr>
72
+ </thead>
73
+ <tbody id="tbody"></tbody>
74
+ </table>
75
+ <div id="loading-skeleton">
76
+ <table><tbody id="skeleton-body"></tbody></table>
77
+ </div>
78
+ </div>
79
+
80
+ <div id="pagination" role="navigation" aria-label="Pagination"></div>
81
+ <div id="error" class="error-banner" style="display:none"></div>
82
+
83
+ <div id="toast-container" class="toast-container" aria-live="polite"></div>
84
+
85
+ <dialog id="confirm-dialog">
86
+ <p id="confirm-message"></p>
87
+ <form method="dialog">
88
+ <button id="confirm-cancel" class="btn" type="reset">Cancel</button>
89
+ <button id="confirm-ok" class="btn btn-primary" type="submit">OK</button>
90
+ </form>
91
+ </dialog>
92
+
93
+ <script src="/app.js"></script>
94
+ </div>
95
+ </body>
96
+ </html>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#7ECBF1"><path d="M480-60q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-73q-11-4-21-9.5T401-389l-63 37q1 5 1.5 10.5t.5 11.5q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-50 35-85t85-35q23 0 43 7.5t36 21.5l62-36q-1-5-1.5-11t-.5-12q0-6 .5-11.5T361-502l-62-37q-16 14-36 21.5t-43 7.5q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 6-.5 12t-1.5 11l63 36q8-8 18-13t21-9v-73q-35-12-57.5-43.5T360-780q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 70.5T520-666v73q11 4 20.5 9.5T558-570l64-38q-1-5-1.5-10.5T620-630q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-23 0-42.5-7.5T662-539l-65 38q1 5 1.5 10.5t.5 10.5q0 5-.5 11t-1.5 11l65 37q16-14 35.5-21.5T740-450q50 0 85 35t35 85q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-6 .5-11.5T622-352l-64-37q-8 8-17.5 13t-20.5 9v74q35 12 57.5 43t22.5 70q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T520-180q0-17-11.5-28.5T480-220q-17 0-28.5 11.5T440-180q0 17 11.5 28.5T480-140ZM220-290q17 0 28.5-11.5T260-330q0-17-11.5-28.5T220-370q-17 0-28.5 11.5T180-330q0 17 11.5 28.5T220-290Zm520 0q17 0 28.5-11.5T780-330q0-17-11.5-28.5T740-370q-17 0-28.5 11.5T700-330q0 17 11.5 28.5T740-290ZM480-440q17 0 28.5-11.5T520-480q0-17-11.5-28.5T480-520q-17 0-28.5 11.5T440-480q0 17 11.5 28.5T480-440ZM220-590q17 0 28.5-11.5T260-630q0-17-11.5-28.5T220-670q-17 0-28.5 11.5T180-630q0 17 11.5 28.5T220-590Zm520 0q17 0 28.5-11.5T780-630q0-17-11.5-28.5T740-670q-17 0-28.5 11.5T700-630q0 17 11.5 28.5T740-590ZM480-740q17 0 28.5-11.5T520-780q0-17-11.5-28.5T480-820q-17 0-28.5 11.5T440-780q0 17 11.5 28.5T480-740Z"/></svg>
@@ -0,0 +1,273 @@
1
+ :root {
2
+ --background: #101010;
3
+ --foreground: #F4F4F5;
4
+ --primary: #7ECBF1;
5
+ --secondary: #A1DCF8;
6
+ --accent: #2a2a2a;
7
+ --border: #2a2a2a;
8
+ --success: #4caf50;
9
+ --warning: #ff9800;
10
+ --info: #2196f3;
11
+ --danger: #DEBAAB;
12
+ --focus-ring: #A1DCF8;
13
+ }
14
+
15
+ html[data-theme="light"] {
16
+ --background: #FFFFFF;
17
+ --foreground: #030507;
18
+ --primary: #0C58A6;
19
+ --secondary: #2B5484;
20
+ --accent: #F3F3F3;
21
+ --border: #d1d5db;
22
+ --danger: #A31E22;
23
+ --focus-ring: #2B5484;
24
+ }
25
+
26
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
27
+
28
+ body {
29
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
30
+ background: var(--background);
31
+ color: var(--foreground);
32
+ padding: 0 20px;
33
+ font-size: 14px;
34
+ line-height: 1.5;
35
+ }
36
+
37
+ #app {
38
+ max-width: 960px;
39
+ margin: 0 auto;
40
+ }
41
+
42
+ header {
43
+ display: flex;
44
+ justify-content: space-between;
45
+ align-items: baseline;
46
+ padding: 12px 0;
47
+ border-bottom: 1px solid var(--border);
48
+ margin-bottom: 16px;
49
+ }
50
+
51
+ header h1 { font-size: 18px; font-weight: 600; }
52
+
53
+ .header-right { display: flex; align-items: center; gap: 12px; }
54
+
55
+ .status-dot {
56
+ width: 10px; height: 10px; border-radius: 50%; background: var(--warning);
57
+ }
58
+ .status-dot.connected { background: var(--success); }
59
+ .status-dot.disconnected { background: var(--danger); }
60
+
61
+ .theme-btn {
62
+ background: none; border: none; color: var(--foreground);
63
+ cursor: pointer; padding: 4px; border-radius: 4px;
64
+ display: flex; align-items: center;
65
+ }
66
+ .theme-btn:hover { background: var(--accent); }
67
+
68
+ .logo-title {
69
+ display: flex; align-items: center; gap: 8px;
70
+ }
71
+
72
+ .theme-btn:focus-visible {
73
+ outline: 2px solid var(--focus-ring);
74
+ outline-offset: 2px;
75
+ }
76
+
77
+ .filters { margin-bottom: 12px; }
78
+
79
+ .filter-row {
80
+ display: flex; gap: 12px; flex-wrap: wrap; align-items: end;
81
+ }
82
+
83
+ .field {
84
+ flex: 1; display: flex; flex-direction: column; gap: 4px;
85
+ }
86
+
87
+ .field label {
88
+ font-size: 11px; text-transform: uppercase;
89
+ color: var(--foreground); letter-spacing: .5px;
90
+ }
91
+
92
+ .field input,
93
+ .field select {
94
+ background: var(--accent); border: 1px solid var(--border);
95
+ color: var(--foreground); padding: 6px 10px;
96
+ border-radius: 4px; font-size: 13px; outline: none; width: 100%;
97
+ }
98
+
99
+ .field input:focus,
100
+ .field select:focus {
101
+ border-color: var(--focus-ring);
102
+ }
103
+
104
+ .btn {
105
+ padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
106
+ background: var(--accent); color: var(--foreground); cursor: pointer;
107
+ font-size: 13px; display: flex; align-items: center; gap: 6px;
108
+ }
109
+ .btn:hover { border-color: var(--primary); color: var(--primary); }
110
+ .btn-primary { background: var(--primary); color: var(--background); border-color: var(--primary); }
111
+ .btn-primary:hover { opacity: 0.9; }
112
+
113
+ .stats {
114
+ color: var(--foreground); opacity: 0.6; font-size: 12px; margin-bottom: 8px;
115
+ }
116
+
117
+ .table-wrap { overflow-x: auto; }
118
+
119
+ table {
120
+ width: 100%; border-collapse: collapse; font-size: 13px;
121
+ }
122
+ thead { border-bottom: 2px solid var(--foreground); }
123
+ th {
124
+ text-align: left; padding: 8px 6px; font-weight: 600;
125
+ opacity: 0.7; white-space: nowrap;
126
+ }
127
+ td { padding: 8px 6px; border-bottom: 1px solid var(--border); vertical-align: middle; }
128
+
129
+ .memory-row { cursor: pointer; }
130
+ .memory-row:hover { background: var(--accent); }
131
+ .memory-row:focus { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
132
+
133
+ .id-cell { opacity: 0.5; font-size: 12px; }
134
+
135
+ .source-badge {
136
+ display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px;
137
+ font-weight: 600; text-transform: uppercase;
138
+ }
139
+ .source-hemisphere { background: var(--secondary); color: var(--background); }
140
+ .source-compend { background: var(--primary); color: var(--background); }
141
+
142
+ .type-badge {
143
+ display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px;
144
+ background: var(--accent); color: var(--foreground);
145
+ }
146
+
147
+ .status-badge {
148
+ display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px;
149
+ }
150
+ .status-shared { background: var(--success); color: #fff; }
151
+ .status-pulled { background: var(--info); color: #fff; }
152
+ .status-unshared { background: var(--accent); color: var(--foreground); opacity: 0.7; }
153
+
154
+ .content-cell {
155
+ max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
156
+ }
157
+
158
+ .time-cell { opacity: 0.5; font-size: 12px; white-space: nowrap; }
159
+
160
+ /* Kebab menu */
161
+ .kebab-menu { position: relative; display: inline-block; }
162
+ .kebab-btn {
163
+ background: none; border: none; color: var(--foreground); cursor: pointer;
164
+ padding: 4px; border-radius: 50%; display: flex; align-items: center;
165
+ }
166
+ .kebab-btn:hover { background: var(--accent); }
167
+ .kebab-dropdown {
168
+ display: none; position: absolute; right: 0; top: 100%; z-index: 10;
169
+ background: var(--background); border: 1px solid var(--border); border-radius: 6px;
170
+ min-width: 140px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); padding: 4px 0;
171
+ }
172
+ .kebab-dropdown.open { display: block; }
173
+ .kebab-item {
174
+ display: block; width: 100%; padding: 8px 14px; text-align: left;
175
+ background: none; border: none; color: var(--foreground); font-size: 13px;
176
+ cursor: pointer;
177
+ }
178
+ .kebab-item:hover { background: var(--accent); }
179
+ .kebab-item.danger { color: var(--danger); }
180
+
181
+ /* Detail row */
182
+ .detail-row td { padding: 0; }
183
+ .detail {
184
+ padding: 12px 16px; background: var(--accent); border-radius: 6px;
185
+ max-height: 0; overflow: hidden; transition: max-height .25s, padding .25s;
186
+ font-size: 13px; white-space: pre-wrap; word-break: break-word;
187
+ line-height: 1.6;
188
+ }
189
+ .detail.open { max-height: 400px; overflow-y: auto; padding: 12px 16px; }
190
+
191
+ /* Skeleton */
192
+ #loading-skeleton { display: none; }
193
+ .skeleton-row td { height: 36px; }
194
+ .skeleton-cell {
195
+ background: linear-gradient(90deg, var(--accent) 25%, transparent 50%, var(--accent) 75%);
196
+ background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px;
197
+ }
198
+ .skeleton-cell.tiny { width: 30px; }
199
+ .skeleton-cell.narrow { width: 70px; }
200
+ .skeleton-cell.wide { width: 200px; }
201
+ @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
202
+
203
+ /* Pagination */
204
+ #pagination {
205
+ display: flex;
206
+ justify-content: center;
207
+ align-items: center;
208
+ gap: 8px;
209
+ padding: 12px;
210
+ font-size: 13px;
211
+ color: var(--foreground);
212
+ }
213
+
214
+ #pagination button {
215
+ background: var(--accent);
216
+ border: 1px solid var(--border);
217
+ color: var(--foreground);
218
+ padding: 4px 12px;
219
+ border-radius: 4px;
220
+ cursor: pointer;
221
+ font-size: 13px;
222
+ }
223
+
224
+ #pagination button:disabled {
225
+ opacity: .3;
226
+ cursor: default;
227
+ }
228
+
229
+ #pagination button:not(:disabled):hover {
230
+ background: color-mix(in srgb, var(--accent) 110%, var(--foreground));
231
+ }
232
+
233
+ #pagination button:not(:disabled):focus-visible {
234
+ outline: 2px solid var(--focus-ring);
235
+ outline-offset: 2px;
236
+ }
237
+
238
+ /* Toasts */
239
+ .toast-container {
240
+ position: fixed; bottom: 20px; right: 20px; z-index: 100;
241
+ display: flex; flex-direction: column; gap: 8px;
242
+ }
243
+ .toast {
244
+ padding: 10px 18px; border-radius: 6px; font-size: 13px;
245
+ color: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
246
+ animation: toastIn .25s;
247
+ }
248
+ .toast-success { background: var(--success); }
249
+ .toast-error { background: var(--danger); }
250
+ .toast-info { background: var(--info); }
251
+ .toast.warning { background: var(--warning); color: #000; }
252
+ .toast.removing { animation: toastOut .25s forwards; }
253
+ @keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
254
+ @keyframes toastOut { to { opacity: 0; transform: translateY(10px); } }
255
+
256
+ /* Confirm dialog */
257
+ dialog {
258
+ background: var(--background); color: var(--foreground); border: 1px solid var(--border);
259
+ border-radius: 8px; padding: 20px; max-width: 360px;
260
+ }
261
+ dialog::backdrop { background: rgba(0,0,0,0.5); }
262
+ dialog p { margin-bottom: 16px; }
263
+ dialog form { display: flex; gap: 8px; justify-content: flex-end; }
264
+
265
+ /* Error banner */
266
+ .error-banner {
267
+ padding: 10px 16px; background: var(--danger); color: #fff;
268
+ border-radius: 6px; margin-top: 8px; font-size: 13px;
269
+ }
270
+
271
+ .empty {
272
+ text-align: center; padding: 40px 20px; opacity: 0.5;
273
+ }
package/dashboard.js ADDED
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+
3
+ import http from 'node:http';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { execSync } from 'node:child_process';
8
+ import { initDb } from './db.js';
9
+ import { getConfig } from './config.js';
10
+ import { createApiHandler, err } from './dashboard/api-handler.js';
11
+
12
+ const arg = process.argv[2] || 'start';
13
+ const PORT = getConfig().port;
14
+
15
+ function killOnPort(port) {
16
+ try {
17
+ const pids = execSync('lsof -ti:' + port, { encoding: 'utf-8' }).trim();
18
+ if (!pids) return;
19
+ for (const pid of pids.split('\n')) {
20
+ try {
21
+ const cmd = execSync('ps -p ' + pid + ' -o comm=', { encoding: 'utf-8' }).trim();
22
+ if (cmd === 'node') process.kill(parseInt(pid), 'SIGTERM');
23
+ } catch {}
24
+ }
25
+ } catch {}
26
+ }
27
+
28
+ if (arg === 'stop') {
29
+ killOnPort(PORT);
30
+ console.log('Cordenar stopped');
31
+ process.exit(0);
32
+ }
33
+ if (arg === 'restart') {
34
+ killOnPort(PORT);
35
+ }
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+ const PUBLIC = path.join(__dirname, 'dashboard', 'public');
39
+
40
+ const DB = initDb();
41
+
42
+ const MIME = {
43
+ '.html': 'text/html; charset=utf-8',
44
+ '.css': 'text/css; charset=utf-8',
45
+ '.js': 'application/javascript; charset=utf-8',
46
+ '.json': 'application/json',
47
+ '.png': 'image/png',
48
+ '.ico': 'image/x-icon',
49
+ '.svg': 'image/svg+xml',
50
+ };
51
+
52
+ const sseClients = new Set();
53
+
54
+ const rateLimit = new Map();
55
+ function checkRate(ip, limit, windowMs) {
56
+ const now = Date.now();
57
+ const entry = rateLimit.get(ip) || { count: 0, reset: now + windowMs };
58
+ if (now > entry.reset) { entry.count = 0; entry.reset = now + windowMs; }
59
+ entry.count++;
60
+ rateLimit.set(ip, entry);
61
+ return entry.count <= limit;
62
+ }
63
+
64
+ function broadcast(type, data) {
65
+ const cleanType = String(type).replace(/[\r\n]/g, '');
66
+ const payload = `event: ${cleanType}\ndata: ${JSON.stringify(data)}\n\n`;
67
+ for (const client of sseClients) {
68
+ client.write(payload);
69
+ }
70
+ }
71
+
72
+ const server = http.createServer((req, res) => {
73
+ res.setHeader('Access-Control-Allow-Origin', 'http://localhost:' + PORT);
74
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
75
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
76
+
77
+ if (req.method === 'OPTIONS') {
78
+ res.writeHead(200);
79
+ res.end();
80
+ return;
81
+ }
82
+
83
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
84
+ const pathname = url.pathname;
85
+ const params = url.searchParams;
86
+
87
+ if (pathname === '/api/events' && req.method === 'GET') {
88
+ if (sseClients.size >= 100) {
89
+ res.writeHead(429, { 'Content-Type': 'text/plain' });
90
+ res.end('Too many connections');
91
+ return;
92
+ }
93
+ res.writeHead(200, {
94
+ 'Content-Type': 'text/event-stream',
95
+ 'Cache-Control': 'no-cache',
96
+ 'Connection': 'keep-alive',
97
+ });
98
+ res.write('\n');
99
+ sseClients.add(res);
100
+ req.on('close', () => { sseClients.delete(res); });
101
+ return;
102
+ }
103
+
104
+ if (pathname === '/api/notify' && req.method === 'POST') {
105
+ let body = '';
106
+ let bodyBytes = 0;
107
+ req.on('data', chunk => {
108
+ bodyBytes += chunk.length;
109
+ if (bodyBytes > 65536) { res.writeHead(413); res.end('Payload too large'); req.destroy(); return; }
110
+ body += chunk.toString();
111
+ });
112
+ req.on('end', () => {
113
+ try {
114
+ const { event, id, source, project } = JSON.parse(body);
115
+ const mappedEvent = /^(memory_|concept_|compend_|project_)/.test(event)
116
+ ? 'synapse_updated'
117
+ : event;
118
+ broadcast(mappedEvent, { id, source, project });
119
+ res.writeHead(200, { 'Content-Type': 'application/json' });
120
+ res.end(JSON.stringify({ ok: true }));
121
+ } catch (e) {
122
+ res.writeHead(400, { 'Content-Type': 'application/json' });
123
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
124
+ }
125
+ });
126
+ return;
127
+ }
128
+
129
+ const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || '127.0.0.1';
130
+ if (pathname.startsWith('/api/') && !checkRate(ip, 50, 1000)) {
131
+ res.writeHead(429, { 'Content-Type': 'text/plain' });
132
+ res.end('Rate limited');
133
+ return;
134
+ }
135
+
136
+ try {
137
+ if (pathname.startsWith('/api/')) {
138
+ const handleApi = createApiHandler(DB);
139
+ const result = handleApi(pathname, req.method, params) || err('Not found', 404);
140
+
141
+ if (result.status === 200) {
142
+ const shareMatch = pathname.match(/^\/api\/synapses\/(\d+)\/share$/);
143
+ if (shareMatch && req.method === 'POST') {
144
+ broadcast('synapse_shared', { id: parseInt(shareMatch[1], 10), source: params.get('source') || '' });
145
+ }
146
+ const unlinkMatch = pathname.match(/^\/api\/synapses\/(\d+)\/unlink$/);
147
+ if (unlinkMatch && req.method === 'POST') {
148
+ broadcast('synapse_unlinked', { id: parseInt(unlinkMatch[1], 10) });
149
+ }
150
+ const unshareMatch = pathname.match(/^\/api\/synapses\/(\d+)\/unshare$/);
151
+ if (unshareMatch && req.method === 'POST') {
152
+ broadcast('synapse_unlinked', { id: parseInt(unshareMatch[1], 10) });
153
+ }
154
+ const pullDeleteMatch = pathname.match(/^\/api\/synapses\/(\d+)\/pull-delete$/);
155
+ if (pullDeleteMatch && req.method === 'POST') {
156
+ broadcast('synapse_unlinked', { id: parseInt(pullDeleteMatch[1], 10) });
157
+ }
158
+ const dismissMatch = pathname.match(/^\/api\/synapses\/(\d+)\/dismiss$/);
159
+ if (dismissMatch && req.method === 'POST') {
160
+ broadcast('synapse_unlinked', { id: parseInt(dismissMatch[1], 10) });
161
+ }
162
+ const syncMatch = pathname.match(/^\/api\/sync$/);
163
+ if (syncMatch && req.method === 'POST') {
164
+ broadcast('sync_complete', { timestamp: Date.now() });
165
+ }
166
+ }
167
+
168
+ res.writeHead(result.status, result.headers);
169
+ res.end(result.body);
170
+ return;
171
+ }
172
+
173
+ const filePath = path.join(PUBLIC, pathname === '/' ? 'index.html' : pathname);
174
+ const resolved = path.resolve(filePath);
175
+ if (!resolved.startsWith(PUBLIC + path.sep)) {
176
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
177
+ res.end('Not found');
178
+ return;
179
+ }
180
+ const ext = path.extname(resolved);
181
+
182
+ fs.readFile(resolved, (err, data) => {
183
+ if (err) {
184
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
185
+ res.end('Not found');
186
+ return;
187
+ }
188
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
189
+ res.end(data);
190
+ });
191
+ } catch (e) {
192
+ console.error('Server error:', e);
193
+ res.writeHead(500, { 'Content-Type': 'application/json' });
194
+ res.end(JSON.stringify({ error: 'Internal server error' }));
195
+ }
196
+ });
197
+
198
+ let closing = false;
199
+ function shutdown() {
200
+ if (closing) return;
201
+ closing = true;
202
+ try { fs.unlinkSync(getConfig().manifestPath); } catch {}
203
+ for (const client of sseClients) { try { client.end(); } catch {} }
204
+ server.close(() => {
205
+ try { DB.close(); } catch {}
206
+ process.exit(0);
207
+ });
208
+ setTimeout(() => process.exit(0), 1000);
209
+ }
210
+ process.on('SIGTERM', shutdown);
211
+ process.on('SIGINT', shutdown);
212
+
213
+ server.listen(PORT, () => {
214
+ console.log('\nCordenar dashboard: http://localhost:' + PORT + '/\n');
215
+
216
+ const manifestPath = getConfig().manifestPath;
217
+ try {
218
+ fs.writeFileSync(manifestPath, JSON.stringify({
219
+ name: 'cordenar',
220
+ notifyEndpoint: `http://127.0.0.1:${PORT}/api/notify`,
221
+ }));
222
+ } catch {}
223
+ });
224
+ server.on('error', (e) => {
225
+ if (e.code === 'EADDRINUSE') {
226
+ console.error('Port ' + PORT + ' is in use. Run "cordenar stop" first.');
227
+ process.exit(1);
228
+ }
229
+ throw e;
230
+ });