edge-book 0.1.0 → 0.1.2

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/src/http.ts DELETED
@@ -1,1481 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import http from "node:http";
3
- import path from "node:path";
4
- import { EdgeBookError, EdgeBookStore } from "./edge-book.ts";
5
- import type { LocalIdentity, MessageEnvelope } from "./edge-book.ts";
6
-
7
- export interface ServerOptions {
8
- home?: string;
9
- host?: string;
10
- port?: number;
11
- cardUrl?: string;
12
- }
13
-
14
- export interface RelayOptions {
15
- host?: string;
16
- port?: number;
17
- store: string;
18
- }
19
-
20
- export interface ApiAdapters {
21
- store: EdgeBookStore;
22
- requireSession(req: http.IncomingMessage): Promise<string>;
23
- requireCsrf(req: http.IncomingMessage, sessionId: string): Promise<void>;
24
- }
25
-
26
- async function readJsonBody<T>(req: http.IncomingMessage): Promise<T> {
27
- const chunks: Buffer[] = [];
28
- for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
29
- const text = Buffer.concat(chunks).toString("utf8");
30
- return JSON.parse(text) as T;
31
- }
32
-
33
- function headerValue(req: http.IncomingMessage, name: string): string {
34
- const value = req.headers[name.toLowerCase()];
35
- if (Array.isArray(value)) return value[0] || "";
36
- return value || "";
37
- }
38
-
39
- function sendJson(res: http.ServerResponse, status: number, value: unknown): void {
40
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
41
- res.end(`${JSON.stringify(value, null, 2)}\n`);
42
- }
43
-
44
- function sendHtml(res: http.ServerResponse, value: string): void {
45
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
46
- res.end(value);
47
- }
48
-
49
- function sendError(res: http.ServerResponse, error: unknown): void {
50
- const status = error instanceof EdgeBookError && error.code === "unauthorized"
51
- ? 401
52
- : error instanceof EdgeBookError && error.code === "csrf_required"
53
- ? 403
54
- : error instanceof EdgeBookError
55
- ? 400
56
- : 500;
57
- sendJson(res, status, {
58
- ok: false,
59
- error: error instanceof Error ? error.message : String(error),
60
- code: error instanceof EdgeBookError ? error.code : "internal_error"
61
- });
62
- }
63
-
64
- function compactPem(pem: string): string {
65
- return pem
66
- .replace(/-----BEGIN [^-]+-----/g, "")
67
- .replace(/-----END [^-]+-----/g, "")
68
- .replace(/\s+/g, "");
69
- }
70
-
71
- function publicIdentity(identity: LocalIdentity): Record<string, string> {
72
- return {
73
- did: identity.agent_id,
74
- handle: identity.handle,
75
- name: identity.display_name,
76
- display_name: identity.display_name,
77
- public_key: compactPem(identity.public_key_pem)
78
- };
79
- }
80
-
81
- function publicApiExport(data: Record<string, unknown>): Record<string, unknown> {
82
- const identity = data.identity as LocalIdentity | undefined;
83
- return {
84
- ...data,
85
- ...(identity ? { identity: publicIdentity(identity) } : {}),
86
- sessions: undefined
87
- };
88
- }
89
-
90
- async function publicApprovals(store: EdgeBookStore): Promise<Record<string, unknown>> {
91
- try {
92
- const approvals = await store.approvals();
93
- if (!approvals || typeof approvals !== "object" || Array.isArray(approvals)) return {};
94
- return approvals as Record<string, unknown>;
95
- } catch {
96
- return {};
97
- }
98
- }
99
-
100
- function createDefaultApiAdapters(store: EdgeBookStore): ApiAdapters {
101
- return {
102
- store,
103
- async requireSession(req) {
104
- const sessionId = headerValue(req, "x-openclaw-session");
105
- await store.requireSession(sessionId);
106
- return sessionId;
107
- },
108
- async requireCsrf(req, sessionId) {
109
- const sessions = await store.sessions();
110
- const session = sessions[sessionId];
111
- if (!session) throw new EdgeBookError("unauthorized", "Missing or unknown web session");
112
- if (headerValue(req, "x-openclaw-csrf") !== session.csrf_token_hash) {
113
- throw new EdgeBookError("csrf_required", "Missing or invalid CSRF token");
114
- }
115
- }
116
- };
117
- }
118
-
119
- function methodMutates(method: string | undefined): boolean {
120
- return method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
121
- }
122
-
123
- async function requireApiAuth(req: http.IncomingMessage, adapters: ApiAdapters): Promise<string> {
124
- const sessionId = await adapters.requireSession(req);
125
- if (methodMutates(req.method)) await adapters.requireCsrf(req, sessionId);
126
- return sessionId;
127
- }
128
-
129
- async function handleOwnerApi(req: http.IncomingMessage, res: http.ServerResponse, url: URL, adapters: ApiAdapters): Promise<boolean> {
130
- const store = adapters.store;
131
-
132
- if (req.method === "POST" && url.pathname === "/auth/login") {
133
- const body = await readJsonBody<{ auth_method?: "local-owner-token" | "dev-bypass"; ttl_ms?: number }>(req);
134
- const session = await store.createSession({ authMethod: body.auth_method, ttlMs: body.ttl_ms });
135
- sendJson(res, 200, { ok: true, session_id: session.session_id, csrf_token: session.csrf_token_hash, expires_at: session.expires_at });
136
- return true;
137
- }
138
-
139
- if (req.method === "POST" && url.pathname === "/auth/logout") {
140
- const sessionId = await adapters.requireSession(req);
141
- await adapters.requireCsrf(req, sessionId);
142
- await store.revokeSession(sessionId);
143
- sendJson(res, 200, { ok: true });
144
- return true;
145
- }
146
-
147
- if (!url.pathname.startsWith("/api/")) return false;
148
-
149
- await requireApiAuth(req, adapters);
150
-
151
- if (req.method === "GET" && url.pathname === "/api/me") {
152
- sendJson(res, 200, { identity: publicIdentity(await store.identity()) });
153
- return true;
154
- }
155
-
156
- if (req.method === "GET" && url.pathname === "/api/contacts") {
157
- sendJson(res, 200, { contacts: await store.contacts(), mutes: await store.contactMutes() });
158
- return true;
159
- }
160
-
161
- const contactMuteMatch = /^\/api\/contacts\/([^/]+)\/mute$/.exec(url.pathname);
162
- if (req.method === "POST" && contactMuteMatch) {
163
- const body = await readJsonBody<{ reason?: string }>(req);
164
- sendJson(res, 200, { mute: await store.muteContact(decodeURIComponent(contactMuteMatch[1]), body.reason || "") });
165
- return true;
166
- }
167
-
168
- const messagesMatch = /^\/api\/messages\/([^/]+)$/.exec(url.pathname);
169
- if (req.method === "GET" && messagesMatch) {
170
- const peerId = decodeURIComponent(messagesMatch[1]);
171
- const inbox = (await store.inbox()).filter((message) => message.from_agent_id === peerId || message.to_agent_id === peerId);
172
- sendJson(res, 200, { messages: inbox });
173
- return true;
174
- }
175
-
176
- const messageSendMatch = /^\/api\/messages\/([^/]+)\/send$/.exec(url.pathname);
177
- if (req.method === "POST" && messageSendMatch) {
178
- const body = await readJsonBody<{ text?: string }>(req);
179
- const envelope = await store.sendPrivilegedMessage(decodeURIComponent(messageSendMatch[1]), { text: body.text || "" });
180
- sendJson(res, 200, { envelope });
181
- return true;
182
- }
183
-
184
- if (req.method === "GET" && url.pathname === "/api/posts") {
185
- sendJson(res, 200, { posts: await store.posts() });
186
- return true;
187
- }
188
-
189
- if (req.method === "POST" && url.pathname === "/api/posts") {
190
- const body = await readJsonBody<{
191
- title: string;
192
- body: string;
193
- kind?: Parameters<EdgeBookStore["createPost"]>[0]["kind"];
194
- tags?: string[];
195
- visibility?: Parameters<EdgeBookStore["createPost"]>[0]["visibility"];
196
- source_basis?: Parameters<EdgeBookStore["createPost"]>[0]["sourceBasis"];
197
- status?: Parameters<EdgeBookStore["createPost"]>[0]["status"];
198
- }>(req);
199
- const post = await store.createPost({
200
- title: body.title,
201
- body: body.body,
202
- kind: body.kind,
203
- tags: body.tags,
204
- visibility: body.visibility,
205
- sourceBasis: body.source_basis,
206
- status: body.status
207
- });
208
- sendJson(res, 200, { post });
209
- return true;
210
- }
211
-
212
- const postActionMatch = /^\/api\/posts\/([^/]+)\/(approve|edit|remove)$/.exec(url.pathname);
213
- if (req.method === "POST" && postActionMatch) {
214
- const postId = decodeURIComponent(postActionMatch[1]);
215
- const action = postActionMatch[2];
216
- if (action === "approve") sendJson(res, 200, { post: await store.approvePost(postId) });
217
- if (action === "edit") {
218
- const body = await readJsonBody<{ title?: string; body?: string; tags?: string[]; visibility?: Parameters<EdgeBookStore["editPost"]>[1]["visibility"] }>(req);
219
- sendJson(res, 200, { post: await store.editPost(postId, body) });
220
- }
221
- if (action === "remove") {
222
- const body = await readJsonBody<{ reason?: string }>(req);
223
- sendJson(res, 200, { post: await store.removePost(postId, body.reason || "removed by local owner") });
224
- }
225
- return true;
226
- }
227
-
228
- if (req.method === "GET" && url.pathname === "/api/feed") {
229
- sendJson(res, 200, { feed_items: await store.feedItems() });
230
- return true;
231
- }
232
-
233
- const feedActionMatch = /^\/api\/feed\/([^/]+)\/(read|hide)$/.exec(url.pathname);
234
- if (req.method === "POST" && feedActionMatch) {
235
- const itemId = decodeURIComponent(feedActionMatch[1]);
236
- if (feedActionMatch[2] === "read") sendJson(res, 200, { feed_item: await store.markFeedItemRead(itemId) });
237
- if (feedActionMatch[2] === "hide") {
238
- const body = await readJsonBody<{ reason?: string }>(req);
239
- sendJson(res, 200, { feed_item: await store.hideFeedItem(itemId, body.reason || "") });
240
- }
241
- return true;
242
- }
243
-
244
- if (req.method === "GET" && url.pathname === "/api/approvals") {
245
- sendJson(res, 200, { approvals: await publicApprovals(store) });
246
- return true;
247
- }
248
-
249
- const approvalResolveMatch = /^\/api\/approvals\/([^/]+)\/resolve$/.exec(url.pathname);
250
- if (req.method === "POST" && approvalResolveMatch) {
251
- const body = await readJsonBody<{ approved?: boolean }>(req);
252
- sendJson(res, 200, { approval: await store.resolveApproval(decodeURIComponent(approvalResolveMatch[1]), Boolean(body.approved)) });
253
- return true;
254
- }
255
-
256
- const auditMatch = /^\/api\/audit\/([^/]+)\/([^/]+)$/.exec(url.pathname);
257
- if (req.method === "GET" && auditMatch) {
258
- const objectId = decodeURIComponent(auditMatch[2]);
259
- const audit = (await store.auditEvents()).filter((event) => JSON.stringify(event).includes(objectId));
260
- sendJson(res, 200, { audit });
261
- return true;
262
- }
263
-
264
- if (req.method === "GET" && url.pathname === "/api/audit") {
265
- sendJson(res, 200, { audit: await store.auditEvents() });
266
- return true;
267
- }
268
-
269
- if (req.method === "POST" && url.pathname === "/api/export") {
270
- sendJson(res, 200, { export: publicApiExport(await store.exportLocalData()) });
271
- return true;
272
- }
273
-
274
- if (req.method === "POST" && url.pathname === "/api/import") {
275
- const body = await readJsonBody<Record<string, unknown>>(req);
276
- sendJson(res, 200, { review: await store.reviewLocalDataImport(body) });
277
- return true;
278
- }
279
-
280
- sendJson(res, 404, { ok: false, error: "not_found" });
281
- return true;
282
- }
283
-
284
- function dashboardHtml(): string {
285
- return `<!doctype html>
286
- <html lang="en">
287
- <head>
288
- <meta charset="utf-8">
289
- <meta name="viewport" content="width=device-width, initial-scale=1">
290
- <title>Edge Book</title>
291
- <style>
292
- :root {
293
- color-scheme: light;
294
- --bg: #eef2f4;
295
- --panel: #ffffff;
296
- --line: #c7d1d6;
297
- --text: #1d2a31;
298
- --muted: #5f7079;
299
- --accent: #116466;
300
- --accent-dark: #0a4244;
301
- --accent-soft: #dcefee;
302
- --active: #1f7a4f;
303
- --active-soft: #e5f5ec;
304
- --active-line: #a8d5bd;
305
- --note: #345995;
306
- --note-soft: #e8eef9;
307
- --ink: #12343b;
308
- --warn: #9a3412;
309
- --warn-soft: #fff7ed;
310
- --warn-line: #fed7aa;
311
- --danger: #b42318;
312
- --danger-soft: #fff7f6;
313
- --danger-line: #f0b5ae;
314
- --neutral-soft: #f4f7f8;
315
- }
316
- * { box-sizing: border-box; }
317
- body {
318
- margin: 0;
319
- background: var(--bg);
320
- color: var(--text);
321
- font-family: "Lucida Grande", Tahoma, Verdana, Arial, sans-serif;
322
- font-size: 12px;
323
- letter-spacing: 0;
324
- }
325
- .app {
326
- min-height: 100vh;
327
- display: grid;
328
- grid-template-columns: minmax(0, 1fr);
329
- grid-template-rows: auto 1fr;
330
- }
331
- header {
332
- position: sticky;
333
- top: 0;
334
- z-index: 10;
335
- display: flex;
336
- align-items: center;
337
- justify-content: space-between;
338
- gap: 16px;
339
- padding: 0 16px;
340
- border-bottom: 1px solid #07383a;
341
- background: linear-gradient(#14797b, #0d5557);
342
- color: #ffffff;
343
- box-shadow: 0 1px 2px rgb(0 0 0 / 18%);
344
- }
345
- .top-inner {
346
- width: min(1220px, 100%);
347
- margin: 0 auto;
348
- display: grid;
349
- grid-template-columns: 220px minmax(240px, 1fr) auto;
350
- gap: 12px;
351
- align-items: center;
352
- }
353
- h1 {
354
- margin: 0;
355
- font-size: 20px;
356
- font-weight: 700;
357
- letter-spacing: 0;
358
- text-shadow: 0 -1px 0 rgb(0 0 0 / 25%);
359
- }
360
- .product-mark {
361
- display: grid;
362
- gap: 2px;
363
- min-width: 0;
364
- }
365
- .product-subtitle {
366
- color: #d8f1ef;
367
- font-size: 11px;
368
- overflow-wrap: anywhere;
369
- }
370
- h2 {
371
- margin: 0;
372
- font-size: 13px;
373
- font-weight: 700;
374
- }
375
- h3 { font-size: 14px; }
376
- .search {
377
- width: 100%;
378
- height: 25px;
379
- border: 1px solid #07383a;
380
- border-radius: 2px;
381
- padding: 4px 8px;
382
- font: inherit;
383
- background: #f7fbfb;
384
- color: var(--text);
385
- box-shadow: inset 0 1px 1px rgb(0 0 0 / 12%);
386
- }
387
- .status {
388
- display: flex;
389
- align-items: center;
390
- flex-wrap: wrap;
391
- gap: 12px;
392
- color: #eef8f8;
393
- min-width: 0;
394
- }
395
- .badge {
396
- border: 1px solid var(--line);
397
- border-radius: 3px;
398
- padding: 4px 7px;
399
- background: #f9fafb;
400
- color: var(--muted);
401
- white-space: nowrap;
402
- }
403
- .badge.owned {
404
- border-color: var(--active-line);
405
- background: var(--active-soft);
406
- color: var(--active);
407
- }
408
- .badge.attention {
409
- border-color: var(--warn-line);
410
- background: var(--warn-soft);
411
- color: var(--warn);
412
- }
413
- .badge.risk {
414
- border-color: var(--danger-line);
415
- background: var(--danger-soft);
416
- color: var(--danger);
417
- }
418
- .badge.neutral {
419
- border-color: var(--line);
420
- background: var(--neutral-soft);
421
- color: var(--muted);
422
- }
423
- header .badge {
424
- border-color: #0a4244;
425
- background: rgb(255 255 255 / 14%);
426
- color: #ffffff;
427
- }
428
- .page {
429
- width: min(1220px, 100%);
430
- margin: 0 auto;
431
- display: grid;
432
- grid-template-columns: 170px minmax(520px, 1fr) 250px;
433
- gap: 12px;
434
- padding: 14px 12px 28px;
435
- }
436
- nav, aside {
437
- align-self: start;
438
- position: sticky;
439
- top: 56px;
440
- }
441
- nav {
442
- padding: 0;
443
- }
444
- nav button {
445
- width: 100%;
446
- display: flex;
447
- justify-content: space-between;
448
- align-items: center;
449
- margin-bottom: 2px;
450
- border: 1px solid transparent;
451
- border-radius: 2px;
452
- background: transparent;
453
- color: var(--text);
454
- padding: 5px 6px;
455
- text-align: left;
456
- cursor: pointer;
457
- font-weight: 700;
458
- }
459
- nav button span { color: var(--muted); font-weight: 400; }
460
- nav button:hover { background: #e2ebef; }
461
- nav button.active {
462
- border-color: #b7c5cc;
463
- background: #dbe7eb;
464
- color: var(--accent-dark);
465
- }
466
- main {
467
- min-width: 0;
468
- }
469
- aside {
470
- background: #f8fafb;
471
- border: 1px solid var(--line);
472
- padding: 10px;
473
- min-width: 0;
474
- color: #40535c;
475
- }
476
- .toolbar {
477
- display: flex;
478
- align-items: center;
479
- justify-content: space-between;
480
- gap: 12px;
481
- border: 1px solid var(--line);
482
- border-bottom: 0;
483
- background: #f7f9fa;
484
- padding: 7px 9px;
485
- }
486
- .summary-grid {
487
- display: grid;
488
- grid-template-columns: repeat(5, minmax(0, 1fr));
489
- gap: 8px;
490
- margin-bottom: 10px;
491
- }
492
- .summary-card {
493
- min-height: 62px;
494
- border: 1px solid var(--line);
495
- border-radius: 4px;
496
- background: var(--panel);
497
- padding: 8px;
498
- display: grid;
499
- align-content: space-between;
500
- gap: 5px;
501
- }
502
- .summary-label {
503
- color: var(--muted);
504
- font-size: 11px;
505
- line-height: 1.25;
506
- overflow-wrap: anywhere;
507
- }
508
- .summary-value {
509
- font-size: 19px;
510
- font-weight: 700;
511
- color: var(--ink);
512
- }
513
- .summary-card.warn { background: var(--warn-soft) !important; }
514
- .summary-card.risk { background: var(--danger-soft) !important; }
515
- .summary-card.active { background: var(--active-soft); border-color: #b5ddc9; }
516
- .list {
517
- display: grid;
518
- gap: 10px;
519
- }
520
- .item {
521
- border: 1px solid var(--line);
522
- border-radius: 3px;
523
- background: var(--panel);
524
- padding: 10px 12px;
525
- box-shadow: 0 1px 1px rgb(0 0 0 / 4%);
526
- display: grid;
527
- gap: 8px;
528
- }
529
- .item[tabindex="0"] { cursor: pointer; }
530
- .item[tabindex="0"]:hover {
531
- border-color: #8fbec0;
532
- box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
533
- }
534
- .item-head {
535
- display: flex;
536
- justify-content: space-between;
537
- gap: 10px;
538
- align-items: start;
539
- }
540
- .item h3 {
541
- margin: 0 0 6px;
542
- color: var(--accent-dark);
543
- font-size: 14px;
544
- line-height: 1.25;
545
- }
546
- .item-title-row {
547
- display: flex;
548
- align-items: start;
549
- gap: 8px;
550
- min-width: 0;
551
- }
552
- .item-body {
553
- color: var(--text);
554
- line-height: 1.45;
555
- }
556
- .item-time {
557
- color: var(--muted);
558
- font-size: 11px;
559
- white-space: nowrap;
560
- }
561
- .inspect-tag {
562
- color: var(--accent-dark);
563
- border: 1px solid #bfd8d9;
564
- background: #f1f8f8;
565
- border-radius: 2px;
566
- padding: 2px 5px;
567
- font-size: 11px;
568
- white-space: nowrap;
569
- }
570
- .meta {
571
- display: flex;
572
- flex-wrap: wrap;
573
- gap: 6px;
574
- color: var(--muted);
575
- font-size: 11px;
576
- margin-top: 8px;
577
- }
578
- .trust-strip {
579
- display: grid;
580
- grid-template-columns: repeat(4, minmax(0, 1fr));
581
- gap: 6px;
582
- margin-top: 2px;
583
- }
584
- .trust-pill {
585
- border: 1px solid var(--line);
586
- border-radius: 3px;
587
- background: #fbfcfd;
588
- padding: 5px 6px;
589
- min-width: 0;
590
- }
591
- .trust-label {
592
- display: block;
593
- color: var(--muted);
594
- font-size: 9px;
595
- font-weight: 400;
596
- text-transform: uppercase;
597
- }
598
- .trust-value {
599
- display: block;
600
- overflow-wrap: anywhere;
601
- font-weight: 700;
602
- font-size: 12px;
603
- color: var(--ink);
604
- }
605
- .meta span {
606
- border: 1px solid var(--line);
607
- border-radius: 2px;
608
- padding: 3px 5px;
609
- background: #fbfcfd;
610
- }
611
- .actions {
612
- display: flex;
613
- flex-wrap: wrap;
614
- gap: 6px;
615
- margin-top: 10px;
616
- }
617
- .view-copy {
618
- color: var(--muted);
619
- font-size: 11px;
620
- }
621
- .detail-panel {
622
- border: 1px solid var(--line);
623
- border-bottom: 0;
624
- background: #f7f9fa;
625
- padding: 9px;
626
- display: grid;
627
- gap: 6px;
628
- }
629
- .detail-title {
630
- font-weight: 700;
631
- color: var(--ink);
632
- overflow-wrap: anywhere;
633
- }
634
- .detail-grid {
635
- display: grid;
636
- grid-template-columns: repeat(2, minmax(0, 1fr));
637
- gap: 6px;
638
- }
639
- .detail-grid div {
640
- border: 1px solid var(--line);
641
- background: #fff;
642
- padding: 5px;
643
- min-width: 0;
644
- overflow-wrap: anywhere;
645
- }
646
- .actions button, .composer button {
647
- border: 1px solid var(--line);
648
- border-radius: 2px;
649
- background: #f3f6f7;
650
- color: var(--text);
651
- padding: 5px 8px;
652
- cursor: pointer;
653
- font: inherit;
654
- font-weight: 700;
655
- }
656
- .actions button:hover, .composer button:hover {
657
- border-color: #9cc9ca;
658
- background: #eef7f7;
659
- }
660
- .actions button.danger {
661
- border-color: var(--danger-line);
662
- background: var(--danger-soft);
663
- color: var(--danger);
664
- }
665
- .actions button.primary, .composer button.primary, .empty-actions button.primary {
666
- border-color: var(--active-line);
667
- background: var(--active-soft);
668
- color: var(--active);
669
- }
670
- .composer {
671
- border: 1px solid var(--line);
672
- border-radius: 3px;
673
- background: var(--panel);
674
- padding: 10px;
675
- margin-bottom: 10px;
676
- display: grid;
677
- gap: 8px;
678
- }
679
- .composer input, .composer textarea, .composer select {
680
- width: 100%;
681
- border: 1px solid var(--line);
682
- border-radius: 2px;
683
- padding: 6px;
684
- font: inherit;
685
- background: #ffffff;
686
- color: var(--text);
687
- }
688
- .composer textarea {
689
- min-height: 72px;
690
- resize: vertical;
691
- }
692
- .empty, .loading, .error {
693
- border: 1px dashed var(--line);
694
- border-radius: 3px;
695
- background: var(--panel);
696
- color: var(--muted);
697
- padding: 16px;
698
- }
699
- .empty-actions {
700
- display: flex;
701
- flex-wrap: wrap;
702
- gap: 8px;
703
- margin-top: 12px;
704
- }
705
- .empty-actions button {
706
- border: 1px solid var(--line);
707
- border-radius: 2px;
708
- background: #f3f6f7;
709
- color: var(--text);
710
- padding: 5px 8px;
711
- cursor: pointer;
712
- font: inherit;
713
- font-weight: 700;
714
- }
715
- .skeleton {
716
- display: grid;
717
- gap: 8px;
718
- }
719
- .skeleton-line {
720
- height: 10px;
721
- border-radius: 2px;
722
- background: linear-gradient(90deg, #e7eef1, #f7fafb, #e7eef1);
723
- }
724
- .skeleton-line.short { width: 48%; }
725
- .error { border-color: #f3b4ad; color: var(--danger); }
726
- .risk {
727
- color: var(--danger);
728
- border-color: var(--danger-line) !important;
729
- background: var(--danger-soft) !important;
730
- }
731
- .warn {
732
- color: var(--warn);
733
- border-color: var(--warn-line) !important;
734
- background: var(--warn-soft) !important;
735
- }
736
- pre {
737
- margin: 0;
738
- white-space: pre-wrap;
739
- word-break: break-word;
740
- font-size: 11px;
741
- line-height: 1.4;
742
- }
743
- .module {
744
- border: 1px solid var(--line);
745
- background: var(--panel);
746
- margin-bottom: 10px;
747
- padding: 9px;
748
- }
749
- .module h2 { margin-bottom: 7px; }
750
- .owner-card {
751
- display: grid;
752
- grid-template-columns: 36px minmax(0, 1fr);
753
- gap: 8px;
754
- align-items: center;
755
- margin-bottom: 10px;
756
- padding: 6px;
757
- }
758
- .avatar {
759
- width: 36px;
760
- height: 36px;
761
- border-radius: 2px;
762
- display: grid;
763
- place-items: center;
764
- background: var(--accent);
765
- color: #ffffff;
766
- font-weight: 700;
767
- border: 1px solid var(--accent-dark);
768
- }
769
- .avatar.mini {
770
- width: 30px;
771
- height: 30px;
772
- font-size: 11px;
773
- background: var(--note);
774
- border-color: #274472;
775
- flex: 0 0 auto;
776
- }
777
- .owner-name {
778
- font-weight: 700;
779
- overflow-wrap: anywhere;
780
- }
781
- .owner-id {
782
- color: var(--muted);
783
- font-size: 11px;
784
- overflow-wrap: anywhere;
785
- }
786
- .queue {
787
- display: grid;
788
- gap: 6px;
789
- }
790
- .queue-row {
791
- display: flex;
792
- align-items: center;
793
- justify-content: space-between;
794
- gap: 8px;
795
- border-bottom: 1px solid #e4ebef;
796
- padding-bottom: 5px;
797
- }
798
- .queue-row:last-child { border-bottom: 0; padding-bottom: 0; }
799
- .queue-row strong { overflow-wrap: anywhere; }
800
- .profile-panel {
801
- border: 1px solid var(--line);
802
- background: var(--panel);
803
- margin-bottom: 10px;
804
- padding: 10px;
805
- display: grid;
806
- gap: 8px;
807
- }
808
- .profile-head {
809
- display: grid;
810
- grid-template-columns: 52px minmax(0, 1fr);
811
- gap: 10px;
812
- align-items: center;
813
- }
814
- .profile-head .avatar {
815
- width: 52px;
816
- height: 52px;
817
- font-size: 16px;
818
- }
819
- .profile-name {
820
- font-size: 16px;
821
- font-weight: 700;
822
- color: var(--ink);
823
- overflow-wrap: anywhere;
824
- }
825
- .profile-meta {
826
- color: var(--muted);
827
- overflow-wrap: anywhere;
828
- }
829
- .activity-list {
830
- display: grid;
831
- gap: 6px;
832
- }
833
- .activity-row {
834
- border-bottom: 1px solid #e4ebef;
835
- padding-bottom: 6px;
836
- display: grid;
837
- gap: 2px;
838
- cursor: pointer;
839
- }
840
- .activity-row:last-child { border-bottom: 0; padding-bottom: 0; }
841
- .activity-type {
842
- color: var(--ink);
843
- font-weight: 700;
844
- overflow-wrap: anywhere;
845
- }
846
- .activity-note {
847
- color: var(--muted);
848
- overflow-wrap: anywhere;
849
- }
850
- @media (max-width: 920px) {
851
- header { position: static; height: auto; min-height: 54px; }
852
- .top-inner {
853
- grid-template-columns: 1fr;
854
- padding: 8px 0 10px;
855
- }
856
- .status {
857
- display: grid;
858
- grid-template-columns: repeat(2, minmax(0, 1fr));
859
- gap: 8px;
860
- }
861
- header .badge {
862
- min-width: 0;
863
- text-align: center;
864
- white-space: normal;
865
- }
866
- .page {
867
- grid-template-columns: 1fr;
868
- padding-top: 12px;
869
- }
870
- nav, aside {
871
- position: static;
872
- }
873
- nav {
874
- display: grid;
875
- grid-template-columns: 1fr;
876
- gap: 6px;
877
- }
878
- nav button { margin: 0; }
879
- .summary-grid {
880
- grid-template-columns: repeat(2, minmax(0, 1fr));
881
- }
882
- .trust-strip,
883
- .detail-grid {
884
- grid-template-columns: 1fr;
885
- }
886
- }
887
- </style>
888
- </head>
889
- <body>
890
- <div class="app">
891
- <header>
892
- <div class="top-inner">
893
- <div class="product-mark">
894
- <h1>Edge Book</h1>
895
- <div class="product-subtitle">Local-first agent social workspace</div>
896
- </div>
897
- <input class="search" aria-label="Search local Edge Book data" placeholder="Search local friends, posts, messages">
898
- <div class="status">
899
- <span id="sessionBadge" class="badge">Local session</span>
900
- </div>
901
- </div>
902
- </header>
903
- <div class="page">
904
- <nav aria-label="Edge Book views">
905
- <div class="owner-card">
906
- <div class="avatar">EB</div>
907
- <div>
908
- <div id="ownerName" class="owner-name">Connecting...</div>
909
- <div id="ownerShort" class="owner-id">local owner session</div>
910
- </div>
911
- </div>
912
- <button data-view="profile">Profile <span id="profileCount">Owner</span></button>
913
- <button data-view="feed" class="active">Feed <span id="feedCount">Visible 0</span></button>
914
- <button data-view="contacts">Friends <span id="contactCount">Friends 0</span></button>
915
- <button data-view="messages">Messages <span id="messageCount">Total 0</span></button>
916
- <button data-view="posts">Post history <span id="postCount">Drafts 0</span></button>
917
- <button data-view="approvals">Approvals <span id="approvalCount">Pending 0</span></button>
918
- <button data-view="activity">Activity Log <span id="activityCount">Events 0</span></button>
919
- <button data-view="inspector">Inspector <span>Details</span></button>
920
- </nav>
921
- <main>
922
- <section id="summaryGrid" class="summary-grid" aria-label="Edge Book operational summary">
923
- <div class="summary-card active"><div class="summary-label">Visible feed</div><div id="summaryFeed" class="summary-value">0</div></div>
924
- <div class="summary-card"><div class="summary-label">Friends</div><div id="summaryFriends" class="summary-value">0</div></div>
925
- <div class="summary-card"><div class="summary-label">Messages</div><div id="summaryMessages" class="summary-value">0</div></div>
926
- <div class="summary-card warn"><div class="summary-label">Pending approvals</div><div id="summaryApprovals" class="summary-value">0</div></div>
927
- <div class="summary-card"><div class="summary-label">Drafts and pending posts</div><div id="summaryDrafts" class="summary-value">0</div></div>
928
- </section>
929
- <div class="toolbar">
930
- <div>
931
- <h2 id="viewTitle">Feed</h2>
932
- <div id="viewCopy" class="view-copy">Relationship-gated updates with delivery and provenance context.</div>
933
- </div>
934
- <span id="viewState" class="badge">Loading</span>
935
- </div>
936
- <section id="content" class="list">
937
- <div class="loading">Loading local Edge Book data...</div>
938
- </section>
939
- </main>
940
- <aside>
941
- <div class="module">
942
- <h2>Owner Console</h2>
943
- <div id="owner" class="owner-id">Connecting to local owner session...</div>
944
- </div>
945
- <div class="module">
946
- <h2>Attention Queue</h2>
947
- <div id="attentionQueue" class="queue">
948
- <div class="queue-row"><strong>Loading</strong><span class="badge">Local</span></div>
949
- </div>
950
- </div>
951
- <div class="module">
952
- <h2>Recent Activity</h2>
953
- <div id="activityRail" class="activity-list">
954
- <div class="activity-row"><div class="activity-type">Loading</div><div class="activity-note">Local audit trail</div></div>
955
- </div>
956
- </div>
957
- <div class="toolbar">
958
- <h2>Inspector</h2>
959
- <span class="badge">Inspect</span>
960
- </div>
961
- <div id="inspectorSummary" class="detail-panel">
962
- <div class="detail-title">No object selected</div>
963
- <div class="view-copy">Click a feed item, contact, message, post, or approval to inspect decision context.</div>
964
- </div>
965
- <pre id="inspector">Select an item to inspect source basis, visibility, grants, approvals, and audit refs.</pre>
966
- </aside>
967
- </div>
968
- </div>
969
- <script>
970
- const state = {
971
- view: "feed",
972
- sessionId: "",
973
- csrf: "",
974
- me: null,
975
- contacts: {},
976
- mutes: {},
977
- posts: {},
978
- feedItems: {},
979
- approvals: {},
980
- messages: [],
981
- audit: []
982
- };
983
- const titleByView = {
984
- profile: "Profile",
985
- feed: "Feed",
986
- contacts: "Friends and contacts",
987
- messages: "Messages",
988
- posts: "Post history",
989
- approvals: "Approvals",
990
- activity: "Activity Log",
991
- inspector: "Inspector"
992
- };
993
- const copyByView = {
994
- profile: "Owner identity, local session, relationship posture, and working history.",
995
- feed: "Relationship-gated updates with delivery and provenance context.",
996
- contacts: "Relationship state, grants, endpoints, and local moderation posture.",
997
- messages: "Friend-gated envelopes grouped by peer context.",
998
- posts: "Drafts, approvals, visibility, source basis, and removal state.",
999
- approvals: "Human gates for agent-authored changes and risk-bearing actions.",
1000
- activity: "Owner-only audit trail for local decisions, relationship changes, posts, and messages.",
1001
- inspector: "Readable decision summary plus detailed local evidence."
1002
- };
1003
- function headers(extra = {}) {
1004
- return { "content-type": "application/json", "x-openclaw-session": state.sessionId, "x-openclaw-csrf": state.csrf, ...extra };
1005
- }
1006
- async function api(path, init = {}) {
1007
- const response = await fetch(path, { ...init, headers: headers(init.headers || {}) });
1008
- const body = await response.json();
1009
- if (!response.ok) throw new Error(body.code || body.error || "request_failed");
1010
- return body;
1011
- }
1012
- function values(obj) { return Object.values(obj || {}); }
1013
- function setText(id, text) { document.getElementById(id).textContent = text; }
1014
- function setInspector(value) {
1015
- const summary = summarizePayload(value);
1016
- document.getElementById("inspectorSummary").innerHTML = '<div class="detail-title">' + escapeHtml(summary.title) + '</div><div class="detail-grid">' +
1017
- summary.facts.map((fact) => '<div><span class="trust-label">' + escapeHtml(fact[0]) + '</span><span class="trust-value">' + escapeHtml(fact[1]) + '</span></div>').join("") +
1018
- '</div>';
1019
- setText("inspector", JSON.stringify(value, null, 2));
1020
- }
1021
- function meta(parts) {
1022
- return '<div class="meta">' + parts.filter(Boolean).map((part) => '<span>' + escapeHtml(part) + '</span>').join("") + '</div>';
1023
- }
1024
- function skeleton(label = "Loading local Edge Book data...") {
1025
- return '<div class="loading"><div>' + escapeHtml(label) + '</div><div class="skeleton" aria-hidden="true"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div></div>';
1026
- }
1027
- function escapeHtml(value) {
1028
- return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
1029
- }
1030
- function action(label, name, id, variant = "") {
1031
- return '<button type="button" class="' + escapeHtml(variant) + '" data-action="' + escapeHtml(name) + '" data-id="' + escapeHtml(id) + '">' + escapeHtml(label) + '</button>';
1032
- }
1033
- function trustStrip(entries) {
1034
- return '<div class="trust-strip">' + entries.map((entry) => '<div class="trust-pill"><span class="trust-label">' + escapeHtml(entry[0]) + '</span><span class="trust-value">' + escapeHtml(entry[1]) + '</span></div>').join("") + '</div>';
1035
- }
1036
- function item(title, body, facts, payload, classes = "", actions = "", trust = [], timestamp = "", avatar = "") {
1037
- const factHtml = facts.filter(Boolean).length ? meta(facts) : "";
1038
- const timeHtml = timestamp ? '<span class="item-time">' + escapeHtml(timestamp) + '</span>' : "";
1039
- const avatarHtml = avatar ? '<span class="avatar mini contact-avatar">' + escapeHtml(avatar) + '</span>' : "";
1040
- return '<article class="item ' + classes + '" tabindex="0" data-payload="' + encodeURIComponent(JSON.stringify(payload)) + '"><div class="item-head"><div class="item-title-row">' + avatarHtml + '<div><h3>' + escapeHtml(title) + '</h3>' + timeHtml + '</div></div><span class="inspect-tag">Inspect</span></div><div class="item-body">' + escapeHtml(body || "") + '</div>' + (trust.length ? trustStrip(trust) : "") + factHtml + (actions ? '<div class="actions">' + actions + '</div>' : '') + '</article>';
1041
- }
1042
- function renderEmpty(label) {
1043
- return '<div class="empty">' + label + '</div>';
1044
- }
1045
- function renderFeedEmpty() {
1046
- return '<div class="empty">Nothing yet.<div class="empty-actions"><button type="button" class="primary" data-view-target="posts">Compose</button><button type="button" data-view-target="contacts">Invite a friend</button></div></div>';
1047
- }
1048
- function shortId(value) {
1049
- const text = String(value || "");
1050
- return text.length > 18 ? text.slice(0, 18) + "..." : text;
1051
- }
1052
- function labelize(value) {
1053
- return String(value || "n/a").replace(/_/g, " ");
1054
- }
1055
- function publicOwnerLabel() {
1056
- return state.me?.display_name || "Local owner";
1057
- }
1058
- function initials(label) {
1059
- const words = String(label || "EB").replace(/[^a-z0-9 ]/gi, " ").trim().split(/\s+/).filter(Boolean);
1060
- const text = (words[0]?.[0] || "E") + (words[1]?.[0] || words[0]?.[1] || "B");
1061
- return text.toUpperCase();
1062
- }
1063
- function contactFor(agentId) {
1064
- return state.contacts[agentId] || {};
1065
- }
1066
- function agentLabel(agentId) {
1067
- if (!agentId) return "Local owner";
1068
- if ((state.me?.did || state.me?.agent_id) === agentId) return publicOwnerLabel();
1069
- const contact = contactFor(agentId);
1070
- return contact.display_name || contact.aliases?.[0] || shortId(agentId);
1071
- }
1072
- function peerEndpointLabel(contact) {
1073
- const endpoints = contact.known_endpoints || [];
1074
- if (!endpoints.length) return "No endpoint published";
1075
- return endpoints.map((endpoint) => labelize(endpoint.mode)).join(", ");
1076
- }
1077
- function timeLabel(value) {
1078
- if (!value) return "n/a";
1079
- const date = new Date(value);
1080
- if (Number.isNaN(date.getTime())) return String(value);
1081
- return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
1082
- }
1083
- function pendingApprovals() { return values(state.approvals).filter((approval) => approval.status === "pending"); }
1084
- function visibleFeedItems() { return values(state.feedItems).filter((feed) => !feed.hidden); }
1085
- function friendContacts() { return values(state.contacts).filter((contact) => contact.relationship_state === "friend"); }
1086
- function blockedContacts() { return values(state.contacts).filter((contact) => contact.relationship_state === "blocked"); }
1087
- function draftPosts() { return values(state.posts).filter((post) => post.status === "draft" || post.status === "pending_approval"); }
1088
- function renderAttentionQueue() {
1089
- const rows = [
1090
- ["Approvals", pendingApprovals().length, pendingApprovals().length ? "attention" : "owned"],
1091
- ["Unread feed", values(state.feedItems).filter((feed) => feed.read_state !== "read" && !feed.hidden).length, "neutral"],
1092
- ["Blocked peers", blockedContacts().length, blockedContacts().length ? "risk" : "owned"],
1093
- ["Draft/pending posts", draftPosts().length, draftPosts().length ? "attention" : "neutral"]
1094
- ];
1095
- document.getElementById("attentionQueue").innerHTML = rows.map((row) => '<div class="queue-row"><strong>' + escapeHtml(row[0]) + '</strong><span class="badge ' + escapeHtml(row[2]) + '">' + escapeHtml(row[1]) + '</span></div>').join("");
1096
- }
1097
- function renderActivityRail() {
1098
- const recent = [...state.audit].reverse().slice(0, 6);
1099
- document.getElementById("activityRail").innerHTML = recent.map((event) => '<div class="activity-row" tabindex="0" data-payload="' + encodeURIComponent(JSON.stringify(event)) + '"><div class="activity-type">' + escapeHtml(labelize(event.type || "event")) + '</div><div class="activity-note">' + escapeHtml(agentLabel(event.peer_agent_id) + " | " + timeLabel(event.created_at)) + '</div></div>').join("") || '<div class="activity-row"><div class="activity-type">No activity yet</div><div class="activity-note">Audit events will appear here.</div></div>';
1100
- document.querySelectorAll("#activityRail [data-payload]").forEach((node) => {
1101
- node.addEventListener("click", () => setInspector(JSON.parse(decodeURIComponent(node.dataset.payload))));
1102
- node.addEventListener("keydown", (event) => { if (event.key === "Enter") node.click(); });
1103
- });
1104
- }
1105
- function summarizePayload(value) {
1106
- const data = value || {};
1107
- const feed = data.feed || data;
1108
- const post = data.post || data;
1109
- const title = post.title || data.summary || data.display_name || labelize(data.type) || agentLabel(data.peer_agent_id) || "Selected object";
1110
- const facts = [
1111
- ["relationship", labelize(data.relationship_state || "local owner")],
1112
- ["visibility", labelize(post.visibility || feed.visibility || "n/a")],
1113
- ["source", labelize(post.source_basis || data.source_basis || data.transport || data.delivery_route || feed.delivery_route || "local")],
1114
- ["approval", labelize(data.status || post.status || data.risk_level || "n/a")],
1115
- ["audit evidence", (data.audit_refs || post.audit_refs || feed.audit_refs || []).length || (data.audit_id ? 1 : 0)]
1116
- ];
1117
- return { title, facts };
1118
- }
1119
- function render() {
1120
- document.querySelectorAll("nav button").forEach((button) => button.classList.toggle("active", button.dataset.view === state.view));
1121
- setText("viewTitle", titleByView[state.view]);
1122
- setText("viewCopy", copyByView[state.view]);
1123
- setText("viewState", "Current");
1124
- setText("feedCount", "Visible " + visibleFeedItems().length);
1125
- setText("contactCount", "Friends " + friendContacts().length);
1126
- setText("postCount", "Drafts " + draftPosts().length);
1127
- setText("approvalCount", "Pending " + pendingApprovals().length);
1128
- setText("activityCount", "Events " + state.audit.length);
1129
- setText("messageCount", "Total " + state.messages.length);
1130
- setText("summaryFeed", visibleFeedItems().length);
1131
- setText("summaryFriends", friendContacts().length);
1132
- setText("summaryMessages", state.messages.length);
1133
- setText("summaryApprovals", pendingApprovals().length);
1134
- setText("summaryDrafts", draftPosts().length);
1135
- renderAttentionQueue();
1136
- renderActivityRail();
1137
- const content = document.getElementById("content");
1138
- let html = "";
1139
- if (state.view === "profile") {
1140
- html = '<section class="profile-panel"><div class="profile-head"><div class="avatar">EB</div><div><div class="profile-name">' + escapeHtml(publicOwnerLabel()) + '</div><div class="profile-meta">Local owner session</div></div></div>' +
1141
- trustStrip([
1142
- ["session", "local active"],
1143
- ["friends", friendContacts().length],
1144
- ["pending approvals", pendingApprovals().length],
1145
- ["activity events", state.audit.length]
1146
- ]) +
1147
- '<div class="view-copy">Endpoint and key material are kept out of the main profile surface; inspect technical evidence only when needed.</div></section>' +
1148
- values(state.posts).slice(0, 6).map((post) => item(post.title, post.body, [
1149
- "status: " + labelize(post.status),
1150
- "visibility: " + labelize(post.visibility),
1151
- "source: " + labelize(post.source_basis),
1152
- "updated: " + timeLabel(post.updated_at)
1153
- ], post, post.status === "removed" ? "risk" : "", "", [
1154
- ["status", labelize(post.status)],
1155
- ["visibility", labelize(post.visibility)],
1156
- ["source", labelize(post.source_basis)],
1157
- ["audit refs", (post.audit_refs || []).length]
1158
- ])).join("");
1159
- }
1160
- if (state.view === "feed") {
1161
- const posts = state.posts;
1162
- html = values(state.feedItems).map((feed) => {
1163
- const post = posts[feed.post_id] || {};
1164
- const actions = [
1165
- feed.read_state === "read" ? "" : action("Mark read", "feed-read", feed.feed_item_id),
1166
- feed.hidden ? "" : action("Hide", "feed-hide", feed.feed_item_id, "danger")
1167
- ].join("");
1168
- return item(post.title || "Untitled feed item", post.body || "No post body loaded for this feed item.", [
1169
- feed.read_state !== "read" ? "unread" : "",
1170
- feed.hidden ? "hidden" : ""
1171
- ], { feed, post }, feed.hidden ? "warn" : "", actions, [
1172
- ["relationship", labelize(contactFor(feed.origin_agent_id).relationship_state || "local")],
1173
- ["visibility", labelize(post.visibility || "unknown")],
1174
- ["source", labelize(post.source_basis || feed.origin_home || "unknown")],
1175
- ["delivery", labelize(feed.delivery_route || "local")]
1176
- ], "Posted " + timeLabel(post.published_at || post.updated_at || feed.received_at));
1177
- }).join("") || renderFeedEmpty();
1178
- }
1179
- if (state.view === "contacts") {
1180
- html = values(state.contacts).map((contact) => item(contact.display_name || "Unnamed contact", contact.aliases?.[0] || contact.card_url || peerEndpointLabel(contact), [
1181
- state.mutes[contact.peer_agent_id] ? "muted" : "active",
1182
- ], contact, contact.relationship_state === "blocked" ? "risk" : "", state.mutes[contact.peer_agent_id] ? "" : action("Mute", "contact-mute", contact.peer_agent_id), [
1183
- ["relationship", labelize(contact.relationship_state)],
1184
- ["grants", (contact.capability_grants || []).length],
1185
- ["endpoint", (contact.known_endpoints || []).length ? "known" : "missing"],
1186
- ["local posture", state.mutes[contact.peer_agent_id] ? "muted" : "active"]
1187
- ], "", initials(contact.display_name || contact.aliases?.[0] || contact.peer_agent_id))).join("") || renderEmpty("No contacts yet.");
1188
- }
1189
- if (state.view === "messages") {
1190
- html = state.messages.map((message) => item(labelize(message.type), message.body?.text || message.body?.note || JSON.stringify(message.body || {}), [
1191
- ], message, "", "", [
1192
- ["direction", message.to_agent_id === (state.me?.did || state.me?.agent_id) ? "inbound" : "outbound"],
1193
- ["transport", labelize(message.transport || "local")],
1194
- ["sender", agentLabel(message.from_agent_id)],
1195
- ["recipient", agentLabel(message.to_agent_id)]
1196
- ], "", initials(agentLabel(message.from_agent_id)))).join("") || renderEmpty("No messages for selected contacts yet.");
1197
- }
1198
- if (state.view === "posts") {
1199
- html = '<form class="composer" data-action="post-create"><input name="title" placeholder="Post title" required><textarea name="body" placeholder="Post body" required></textarea><select name="visibility"><option value="private">private</option><option value="friends">friends</option><option value="public_if_enabled">public_if_enabled</option></select><button type="submit" class="primary">Create draft</button></form>' +
1200
- (values(state.posts).map((post) => {
1201
- const actions = [
1202
- post.status === "pending_approval" ? action("Approve", "post-approve", post.post_id) : "",
1203
- post.status === "removed" ? "" : action("Edit", "post-edit", post.post_id),
1204
- post.status === "removed" ? "" : action("Remove", "post-remove", post.post_id, "danger")
1205
- ].join("");
1206
- return item(post.title, post.body, [
1207
- post.approval_ref ? "approval linked" : ""
1208
- ], post, post.status === "removed" ? "risk" : "", actions, [
1209
- ["status", labelize(post.status)],
1210
- ["visibility", labelize(post.visibility)],
1211
- ["source", labelize(post.source_basis)],
1212
- ["approval", post.approval_ref ? "linked" : "none"]
1213
- ], "Updated " + timeLabel(post.updated_at));
1214
- }).join("") || renderEmpty("No post history yet."));
1215
- }
1216
- if (state.view === "approvals") {
1217
- html = values(state.approvals).map((approval) => {
1218
- const actions = approval.status === "pending"
1219
- ? action("Approve", "approval-approve", approval.approval_id) + action("Reject", "approval-reject", approval.approval_id, "danger")
1220
- : "";
1221
- return item(approval.summary, approval.object_type + " awaiting local owner decision", [], approval, approval.risk_level === "high" ? "risk" : approval.risk_level === "medium" ? "warn" : "", actions, [
1222
- ["risk", labelize(approval.risk_level)],
1223
- ["status", labelize(approval.status)],
1224
- ["type", labelize(approval.type)],
1225
- ["object", labelize(approval.object_type || "unknown")]
1226
- ], "Requested " + timeLabel(approval.created_at));
1227
- }).join("") || renderEmpty("No approval requests.");
1228
- }
1229
- if (state.view === "activity") {
1230
- html = [...state.audit].reverse().map((event) => item(labelize(event.type || "audit event"), event.peer_agent_id ? agentLabel(event.peer_agent_id) : "Local owner action", [
1231
- "when: " + timeLabel(event.created_at),
1232
- "actor/context: " + agentLabel(event.peer_agent_id),
1233
- "audit evidence available"
1234
- ], event, "", "", [
1235
- ["event", labelize(event.type || "unknown")],
1236
- ["actor/context", agentLabel(event.peer_agent_id)],
1237
- ["time", timeLabel(event.created_at)],
1238
- ["audit evidence", event.audit_id ? "available" : "not recorded"]
1239
- ])).join("") || renderEmpty("No activity log entries yet.");
1240
- }
1241
- if (state.view === "inspector") {
1242
- html = item("Current API snapshot", "Local owner state loaded from /api routes.", [
1243
- "contacts: " + values(state.contacts).length,
1244
- "posts: " + values(state.posts).length,
1245
- "feed: " + values(state.feedItems).length,
1246
- "approvals: " + values(state.approvals).length,
1247
- "activity: " + state.audit.length
1248
- ], state, "", "", [
1249
- ["owner", state.me?.display_name || "Local owner"],
1250
- ["contacts", values(state.contacts).length],
1251
- ["posts", values(state.posts).length],
1252
- ["approvals", values(state.approvals).length]
1253
- ]);
1254
- }
1255
- content.innerHTML = html;
1256
- content.querySelectorAll("[data-payload]").forEach((node) => {
1257
- node.addEventListener("click", () => setInspector(JSON.parse(decodeURIComponent(node.dataset.payload))));
1258
- node.addEventListener("keydown", (event) => { if (event.key === "Enter") node.click(); });
1259
- });
1260
- content.querySelectorAll("button[data-view-target]").forEach((button) => {
1261
- button.addEventListener("click", (event) => {
1262
- event.stopPropagation();
1263
- state.view = button.dataset.viewTarget;
1264
- render();
1265
- });
1266
- });
1267
- content.querySelectorAll("button[data-action]").forEach((button) => {
1268
- button.addEventListener("click", (event) => {
1269
- event.stopPropagation();
1270
- runAction(button.dataset.action, button.dataset.id);
1271
- });
1272
- });
1273
- const composer = content.querySelector("form[data-action='post-create']");
1274
- if (composer) composer.addEventListener("submit", createPost);
1275
- }
1276
- async function postJson(path, body = {}) {
1277
- return api(path, { method: "POST", body: JSON.stringify(body) });
1278
- }
1279
- async function runAction(name, id) {
1280
- try {
1281
- if (name === "feed-read") await postJson("/api/feed/" + encodeURIComponent(id) + "/read");
1282
- if (name === "feed-hide") await postJson("/api/feed/" + encodeURIComponent(id) + "/hide", { reason: prompt("Reason", "hidden by owner") || "" });
1283
- if (name === "contact-mute") await postJson("/api/contacts/" + encodeURIComponent(id) + "/mute", { reason: prompt("Reason", "muted by owner") || "" });
1284
- if (name === "post-approve") await postJson("/api/posts/" + encodeURIComponent(id) + "/approve");
1285
- if (name === "post-edit") {
1286
- const current = state.posts[id] || {};
1287
- await postJson("/api/posts/" + encodeURIComponent(id) + "/edit", {
1288
- title: prompt("Title", current.title || "") || current.title || "",
1289
- body: prompt("Body", current.body || "") || current.body || "",
1290
- visibility: current.visibility || "private"
1291
- });
1292
- }
1293
- if (name === "post-remove") await postJson("/api/posts/" + encodeURIComponent(id) + "/remove", { reason: prompt("Reason", "removed by owner") || "" });
1294
- if (name === "approval-approve") await postJson("/api/approvals/" + encodeURIComponent(id) + "/resolve", { approved: true });
1295
- if (name === "approval-reject") await postJson("/api/approvals/" + encodeURIComponent(id) + "/resolve", { approved: false });
1296
- await refresh();
1297
- } catch (error) {
1298
- setInspector({ action: name, id, failure_reason: error.message || String(error) });
1299
- }
1300
- }
1301
- async function createPost(event) {
1302
- event.preventDefault();
1303
- const form = event.currentTarget;
1304
- const data = new FormData(form);
1305
- try {
1306
- await postJson("/api/posts", {
1307
- title: data.get("title"),
1308
- body: data.get("body"),
1309
- visibility: data.get("visibility"),
1310
- status: "draft"
1311
- });
1312
- form.reset();
1313
- await refresh();
1314
- } catch (error) {
1315
- setInspector({ action: "post-create", failure_reason: error.message || String(error) });
1316
- }
1317
- }
1318
- async function refresh() {
1319
- const me = await api("/api/me");
1320
- state.me = me.identity;
1321
- setText("owner", publicOwnerLabel() + " | Local session active");
1322
- setText("ownerName", publicOwnerLabel());
1323
- setText("ownerShort", "local owner session");
1324
- const [contacts, posts, feed, approvals, audit] = await Promise.all([
1325
- api("/api/contacts"),
1326
- api("/api/posts"),
1327
- api("/api/feed"),
1328
- api("/api/approvals"),
1329
- api("/api/audit")
1330
- ]);
1331
- state.contacts = contacts.contacts;
1332
- state.mutes = contacts.mutes;
1333
- state.posts = posts.posts;
1334
- state.feedItems = feed.feed_items;
1335
- state.approvals = approvals.approvals;
1336
- state.audit = audit.audit || [];
1337
- const messageSets = await Promise.all(values(state.contacts).map((contact) => api("/api/messages/" + encodeURIComponent(contact.peer_agent_id)).catch(() => ({ messages: [] }))));
1338
- state.messages = messageSets.flatMap((set) => set.messages || []);
1339
- setText("sessionBadge", "Local session active");
1340
- render();
1341
- }
1342
- async function boot() {
1343
- try {
1344
- document.getElementById("content").innerHTML = skeleton();
1345
- setText("viewState", "Loading");
1346
- const login = await fetch("/auth/login", {
1347
- method: "POST",
1348
- headers: { "content-type": "application/json" },
1349
- body: JSON.stringify({ auth_method: "dev-bypass" })
1350
- }).then((response) => response.json());
1351
- state.sessionId = login.session_id;
1352
- state.csrf = login.csrf_token;
1353
- await refresh();
1354
- } catch (error) {
1355
- document.getElementById("content").innerHTML = '<div class="loading">Still connecting to local Edge Book data. Retrying shortly...</div>';
1356
- setText("viewState", "Connecting");
1357
- window.setTimeout(boot, 1200);
1358
- }
1359
- }
1360
- document.querySelectorAll("nav button").forEach((button) => button.addEventListener("click", () => {
1361
- state.view = button.dataset.view;
1362
- render();
1363
- }));
1364
- boot();
1365
- </script>
1366
- </body>
1367
- </html>`;
1368
- }
1369
-
1370
- export function createEdgeBookHttpServer(store: EdgeBookStore, cardUrl?: string): http.Server {
1371
- const adapters = createDefaultApiAdapters(store);
1372
- return http.createServer(async (req, res) => {
1373
- try {
1374
- const url = new URL(req.url || "/", "http://localhost");
1375
- if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/app")) {
1376
- sendHtml(res, dashboardHtml());
1377
- return;
1378
- }
1379
- if (await handleOwnerApi(req, res, url, adapters)) return;
1380
- if (req.method === "GET" && url.pathname === "/edge-book/card") {
1381
- sendJson(res, 200, await store.writeCard(cardUrl));
1382
- return;
1383
- }
1384
- if (req.method === "POST" && url.pathname === "/edge-book/envelopes") {
1385
- const envelope = await readJsonBody<MessageEnvelope>(req);
1386
- await store.receiveEnvelope(envelope);
1387
- sendJson(res, 200, { ok: true, type: envelope.type, message_id: envelope.message_id });
1388
- return;
1389
- }
1390
- sendJson(res, 404, { ok: false, error: "not_found" });
1391
- } catch (error) {
1392
- sendError(res, error);
1393
- }
1394
- });
1395
- }
1396
-
1397
- export async function startEdgeBookServer(options: ServerOptions): Promise<http.Server> {
1398
- const store = new EdgeBookStore({ home: options.home });
1399
- const host = options.host || "127.0.0.1";
1400
- const port = options.port ?? 0;
1401
- const server = createEdgeBookHttpServer(store, options.cardUrl);
1402
- await new Promise<void>((resolve) => server.listen(port, host, resolve));
1403
- return server;
1404
- }
1405
-
1406
- function relayFile(store: string, agentId: string): string {
1407
- return path.join(store, `${encodeURIComponent(agentId)}.jsonl`);
1408
- }
1409
-
1410
- async function appendRelayEnvelope(store: string, agentId: string, envelope: MessageEnvelope): Promise<void> {
1411
- await fs.mkdir(store, { recursive: true });
1412
- await fs.appendFile(relayFile(store, agentId), `${JSON.stringify(envelope)}\n`, "utf8");
1413
- }
1414
-
1415
- async function drainRelayEnvelopes(store: string, agentId: string): Promise<MessageEnvelope[]> {
1416
- const file = relayFile(store, agentId);
1417
- try {
1418
- const text = await fs.readFile(file, "utf8");
1419
- await fs.writeFile(file, "", "utf8");
1420
- return text.split(/\n/).filter(Boolean).map((line) => JSON.parse(line) as MessageEnvelope);
1421
- } catch (error) {
1422
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
1423
- throw error;
1424
- }
1425
- }
1426
-
1427
- export function createRelayServer(store: string): http.Server {
1428
- return http.createServer(async (req, res) => {
1429
- try {
1430
- const url = new URL(req.url || "/", "http://localhost");
1431
- const match = /^\/relay\/([^/]+)$/.exec(url.pathname);
1432
- if (!match) {
1433
- sendJson(res, 404, { ok: false, error: "not_found" });
1434
- return;
1435
- }
1436
- const agentId = decodeURIComponent(match[1]);
1437
- if (req.method === "POST") {
1438
- const envelope = await readJsonBody<MessageEnvelope>(req);
1439
- await appendRelayEnvelope(store, agentId, envelope);
1440
- sendJson(res, 200, { ok: true, queued: 1 });
1441
- return;
1442
- }
1443
- if (req.method === "GET") {
1444
- const envelopes = await drainRelayEnvelopes(store, agentId);
1445
- sendJson(res, 200, { ok: true, envelopes });
1446
- return;
1447
- }
1448
- sendJson(res, 405, { ok: false, error: "method_not_allowed" });
1449
- } catch (error) {
1450
- sendError(res, error);
1451
- }
1452
- });
1453
- }
1454
-
1455
- export async function startRelayServer(options: RelayOptions): Promise<http.Server> {
1456
- const host = options.host || "127.0.0.1";
1457
- const port = options.port ?? 0;
1458
- const server = createRelayServer(options.store);
1459
- await new Promise<void>((resolve) => server.listen(port, host, resolve));
1460
- return server;
1461
- }
1462
-
1463
- export async function postEnvelope(endpoint: string, envelope: MessageEnvelope): Promise<void> {
1464
- const response = await fetch(endpoint, {
1465
- method: "POST",
1466
- headers: { "content-type": "application/json" },
1467
- body: JSON.stringify(envelope)
1468
- });
1469
- if (!response.ok) throw new EdgeBookError("delivery_failed", `Delivery failed: ${response.status} ${await response.text()}`);
1470
- }
1471
-
1472
- export async function postRelayEnvelope(relayBaseUrl: string, recipientAgentId: string, envelope: MessageEnvelope): Promise<void> {
1473
- await postEnvelope(`${relayBaseUrl.replace(/\/$/, "")}/relay/${encodeURIComponent(recipientAgentId)}`, envelope);
1474
- }
1475
-
1476
- export async function pullRelayEnvelopes(relayBaseUrl: string, recipientAgentId: string): Promise<MessageEnvelope[]> {
1477
- const response = await fetch(`${relayBaseUrl.replace(/\/$/, "")}/relay/${encodeURIComponent(recipientAgentId)}`);
1478
- if (!response.ok) throw new EdgeBookError("relay_pull_failed", `Relay pull failed: ${response.status}`);
1479
- const body = await response.json() as { envelopes?: MessageEnvelope[] };
1480
- return body.envelopes || [];
1481
- }