edge-book 0.8.1 → 0.9.0

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.
Files changed (2) hide show
  1. package/dist/edge-book.js +374 -142
  2. package/package.json +1 -1
package/dist/edge-book.js CHANGED
@@ -49,6 +49,9 @@ var FEED_FILE = "feed-items.json";
49
49
  var APPROVALS_FILE = "approvals.json";
50
50
  var ESCALATIONS_FILE = "escalations.json";
51
51
  var CONTACT_MUTES_FILE = "contact-mutes.json";
52
+ var REPORTS_FILE = "reports.json";
53
+ var INVITE_CODES_FILE = "invite-codes.json";
54
+ var INBOUND_RATE_FILE = "inbound-rate.json";
52
55
  var ATTESTATIONS_FILE = "attestations.json";
53
56
  var ENDORSEMENTS_FILE = "endorsements.json";
54
57
  var SIGNALS_FILE = "signals.json";
@@ -285,6 +288,10 @@ var EdgeBookStore = class {
285
288
  if (input.direct_url !== void 0) next.direct_url = input.direct_url;
286
289
  if (input.relay_url !== void 0) next.relay_url = input.relay_url;
287
290
  if (input.notify_on_friend_request !== void 0) next.notify_on_friend_request = input.notify_on_friend_request;
291
+ if (input.open_friend_requests !== void 0) next.open_friend_requests = input.open_friend_requests;
292
+ if (input.inbound_max_per_peer !== void 0) next.inbound_max_per_peer = input.inbound_max_per_peer;
293
+ if (input.inbound_max_global !== void 0) next.inbound_max_global = input.inbound_max_global;
294
+ if (input.inbound_window_ms !== void 0) next.inbound_window_ms = input.inbound_window_ms;
288
295
  await writeJson(this.file(CONFIG_FILE), next);
289
296
  return next;
290
297
  }
@@ -459,7 +466,7 @@ var EdgeBookStore = class {
459
466
  await this.audit(`relationship.${type}`, peerAgentId, { previous, next: nextState, reason });
460
467
  return event;
461
468
  }
462
- async createFriendRequest(targetCard, note = "") {
469
+ async createFriendRequest(targetCard, note = "", inviteCode = "") {
463
470
  const identity = await this.identity();
464
471
  validateCard(targetCard);
465
472
  const existing = (await this.contacts())[targetCard.agent_id];
@@ -467,6 +474,7 @@ var EdgeBookStore = class {
467
474
  await this.upsertContactFromCard(targetCard, "request_sent");
468
475
  await this.setRelationship(targetCard.agent_id, "request_sent", "FriendRequest", note);
469
476
  const card = await this.writeCard();
477
+ const body = { card, note, ...inviteCode ? { invite_code: inviteCode } : {} };
470
478
  return this.signEnvelope({
471
479
  type: "friend_request",
472
480
  to_agent_id: targetCard.agent_id,
@@ -474,15 +482,56 @@ var EdgeBookStore = class {
474
482
  capability_id: "",
475
483
  ref: "",
476
484
  transport: "local",
477
- body: { card, note }
485
+ body
478
486
  });
479
487
  }
488
+ // NOTE — concurrency + sybil-defense assumptions (v1):
489
+ // The GLOBAL cap (inbound_max_global) is the real sybil defense: it limits total
490
+ // inbound load regardless of how many distinct identities an attacker mints.
491
+ // The per-peer cap only slows a single persistent identity; it provides weaker
492
+ // protection because `from_agent_id` is attacker-mintable (any key can be generated).
493
+ //
494
+ // The rate file is read-modify-write. This is safe under the assumption that the
495
+ // receive loop is effectively serial for a single-owner edge agent (one active
496
+ // session at a time). Concurrent receives — e.g. two simultaneous HTTP deliveries
497
+ // on a multi-machine deployment — could undercount hits, allowing bursts past the
498
+ // cap. A shared atomic lock or external counter store is the follow-up (ea-claude-090).
499
+ async enforceInboundRate(peerAgentId) {
500
+ const config = await this.config();
501
+ const windowMs = config.inbound_window_ms ?? 36e5;
502
+ const maxPeer = config.inbound_max_per_peer ?? 5;
503
+ const maxGlobal = config.inbound_max_global ?? 60;
504
+ const cutoff = Date.now() - windowMs;
505
+ const all = await readJson(this.file(INBOUND_RATE_FILE), {});
506
+ for (const k of Object.keys(all)) {
507
+ all[k] = all[k].filter((t) => t > cutoff);
508
+ if (!all[k].length) delete all[k];
509
+ }
510
+ const peerCount = (all[peerAgentId] ?? []).length;
511
+ const globalCount = Object.values(all).reduce((n, arr) => n + arr.length, 0);
512
+ if (peerCount >= maxPeer || globalCount >= maxGlobal) {
513
+ await this.audit("inbound.rate_limited", peerAgentId, { peerCount, globalCount });
514
+ throw new EdgeBookError("rate_limited", "Inbound request rate limit exceeded");
515
+ }
516
+ all[peerAgentId] = [...all[peerAgentId] ?? [], Date.now()];
517
+ await writeJson(this.file(INBOUND_RATE_FILE), all);
518
+ }
480
519
  async receiveFriendRequest(envelope) {
481
520
  await this.verifyEnvelope(envelope);
482
521
  if (envelope.type !== "friend_request") throw new EdgeBookError("wrong_message_type", "Expected friend_request envelope");
522
+ await this.enforceInboundRate(envelope.from_agent_id);
483
523
  const body = envelope.body;
484
524
  validateCard(body.card);
485
525
  if (body.card.agent_id !== envelope.from_agent_id) throw new EdgeBookError("agent_id_mismatch", "Friend request card does not match sender");
526
+ if ((await this.config()).open_friend_requests === false) {
527
+ const ALLOWED_INVITE_BYPASS = ["request_sent", "friend"];
528
+ const known = (await this.contacts())[envelope.from_agent_id]?.relationship_state;
529
+ const allowed = known !== void 0 && ALLOWED_INVITE_BYPASS.includes(known) || (body.invite_code ? await this.consumeInviteCode(body.invite_code) : false);
530
+ if (!allowed) {
531
+ await this.audit("inbound.unsolicited_dropped", envelope.from_agent_id, {});
532
+ throw new EdgeBookError("unsolicited_dropped", "Invite-only: unsolicited request without a valid invite code");
533
+ }
534
+ }
486
535
  const contact = await this.upsertContactFromCard(body.card, "request_received");
487
536
  await this.setRelationship(envelope.from_agent_id, "request_received", "FriendRequest", body.note);
488
537
  await appendJsonl(this.file(INBOX_FILE), envelope);
@@ -658,6 +707,66 @@ var EdgeBookStore = class {
658
707
  async block(peerAgentId) {
659
708
  await this.setRelationship(peerAgentId, "blocked", "Block", "blocked");
660
709
  }
710
+ async reports() {
711
+ return readJson(this.file(REPORTS_FILE), []);
712
+ }
713
+ async inviteCodes() {
714
+ return readJson(this.file(INVITE_CODES_FILE), []);
715
+ }
716
+ async mintInviteCode(opts = {}) {
717
+ const invite = {
718
+ code: randomId("invite"),
719
+ created_at: now(),
720
+ expires_at: opts.ttlMs ? new Date(Date.now() + opts.ttlMs).toISOString() : "",
721
+ max_uses: opts.maxUses ?? 0,
722
+ uses: 0
723
+ };
724
+ const codes = await this.inviteCodes();
725
+ codes.push(invite);
726
+ await writeJson(this.file(INVITE_CODES_FILE), codes);
727
+ return invite;
728
+ }
729
+ // NOTE — serial-receive assumption (v1):
730
+ // consumeInviteCode is read-modify-write. Under concurrent receives, two requests
731
+ // carrying the same single-use code could both read uses=0, both pass the max_uses
732
+ // check, and both increment — effectively spending the code twice. This is safe for
733
+ // a single-owner serial receive loop; a locking primitive is needed for concurrent
734
+ // multi-machine deployments (ties to the same ea-claude-090 follow-up).
735
+ async consumeInviteCode(code) {
736
+ const codes = await this.inviteCodes();
737
+ const idx = codes.findIndex((c) => c.code === code);
738
+ if (idx === -1) return false;
739
+ const invite = codes[idx];
740
+ if (invite.expires_at && new Date(invite.expires_at) < /* @__PURE__ */ new Date()) return false;
741
+ if (invite.max_uses > 0 && invite.uses >= invite.max_uses) return false;
742
+ invite.uses += 1;
743
+ codes[idx] = invite;
744
+ await writeJson(this.file(INVITE_CODES_FILE), codes);
745
+ return true;
746
+ }
747
+ async reportPeer(peerAgentId, reason = "", opts = {}) {
748
+ const auditRef = await this.audit("peer.reported", peerAgentId, { reason, block: Boolean(opts.block) });
749
+ let actuallyBlocked = false;
750
+ if (opts.block) {
751
+ const contacts = await this.contacts();
752
+ if (contacts[peerAgentId]) {
753
+ await this.block(peerAgentId);
754
+ actuallyBlocked = true;
755
+ }
756
+ }
757
+ const rec = {
758
+ report_id: randomId("report"),
759
+ peer_agent_id: peerAgentId,
760
+ reason,
761
+ blocked: actuallyBlocked,
762
+ created_at: now(),
763
+ audit_refs: [auditRef]
764
+ };
765
+ const existingReports = await readJson(this.file(REPORTS_FILE), []);
766
+ existingReports.push(rec);
767
+ await writeJson(this.file(REPORTS_FILE), existingReports);
768
+ return rec;
769
+ }
661
770
  async issueGrant(subjectAgentId, scopes, expiresAt = "") {
662
771
  const identity = await this.identity();
663
772
  const unsigned = {
@@ -1245,6 +1354,7 @@ var EdgeBookStore = class {
1245
1354
  async receiveObjectShare(envelope) {
1246
1355
  await this.verifyEnvelope(envelope);
1247
1356
  if (envelope.type !== "object_share") throw new EdgeBookError("wrong_message_type", "Expected object_share envelope");
1357
+ await this.enforceInboundRate(envelope.from_agent_id);
1248
1358
  const identity = await this.identity();
1249
1359
  const body = envelope.body;
1250
1360
  const { object, grant } = body;
@@ -2193,6 +2303,159 @@ async function runTwoAgentHarness(baseDir) {
2193
2303
  import fs2 from "fs/promises";
2194
2304
  import http from "http";
2195
2305
  import path2 from "path";
2306
+
2307
+ // src/resolver.ts
2308
+ function nextAction(result, target) {
2309
+ switch (result.status) {
2310
+ case "resolved":
2311
+ return `friend request ${target} --deliver`;
2312
+ case "approval_required":
2313
+ case "candidates": {
2314
+ const first = result.candidates?.[0];
2315
+ return first ? `candidates list # then: friend request ${first.candidate_id}` : "candidates list";
2316
+ }
2317
+ default:
2318
+ return "(no match \u2014 check the target)";
2319
+ }
2320
+ }
2321
+ var localContactProvider = {
2322
+ name: "local",
2323
+ priority: 100,
2324
+ async resolve(store, target) {
2325
+ const contacts = await store.contacts();
2326
+ const match = Object.values(contacts).find(
2327
+ (c) => c.peer_agent_id === target || c.aliases.includes(target) || c.display_name === target
2328
+ );
2329
+ if (!match) return null;
2330
+ return {
2331
+ kind: "card",
2332
+ agent_id: match.peer_agent_id,
2333
+ provenance: {
2334
+ source: "local",
2335
+ confidence: "high",
2336
+ display_name: match.display_name,
2337
+ reason: `known contact (relationship_state=${match.relationship_state})`
2338
+ }
2339
+ };
2340
+ }
2341
+ };
2342
+ function cardProvider(name, source, match) {
2343
+ return {
2344
+ name,
2345
+ priority: 90,
2346
+ async resolve(_store, target) {
2347
+ if (!match(target)) return null;
2348
+ const card = await loadCard(target);
2349
+ return {
2350
+ kind: "card",
2351
+ card,
2352
+ agent_id: card.agent_id,
2353
+ provenance: { source, confidence: "high", display_name: card.handle, reason: `${source} card verified` }
2354
+ };
2355
+ }
2356
+ };
2357
+ }
2358
+ var inviteProvider = cardProvider("invite", "invite", (t) => t.startsWith("edgebook:invite:"));
2359
+ var cardUrlProvider = cardProvider("card_url", "card_url", (t) => /^https?:\/\//.test(t));
2360
+ var cardFileProvider = cardProvider(
2361
+ "card_file",
2362
+ "card_file",
2363
+ (t) => t.startsWith("file://") || t.startsWith("/") || t.startsWith("./") || t.endsWith(".json")
2364
+ );
2365
+ var CANDIDATES_FILE = "candidates.json";
2366
+ function candidateKey(c) {
2367
+ return `${c.source}:${c.card_url ?? c.agent_id ?? ""}`;
2368
+ }
2369
+ async function readCandidates(store) {
2370
+ return readJson(store.file(CANDIDATES_FILE), {});
2371
+ }
2372
+ async function listCandidates(store) {
2373
+ return Object.values(await readCandidates(store));
2374
+ }
2375
+ async function getCandidate(store, id) {
2376
+ return (await readCandidates(store))[id];
2377
+ }
2378
+ async function writeCandidate(store, input) {
2379
+ const map = await readCandidates(store);
2380
+ const existing = Object.values(map).find((c) => candidateKey(c) === candidateKey(input));
2381
+ if (existing) return existing;
2382
+ const candidate = {
2383
+ candidate_id: randomId("cand"),
2384
+ approved: false,
2385
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
2386
+ ...input
2387
+ };
2388
+ map[candidate.candidate_id] = candidate;
2389
+ await writeJson(store.file(CANDIDATES_FILE), map);
2390
+ await store.audit("candidate.write", candidate.agent_id ?? "", { candidate_id: candidate.candidate_id, source: candidate.source });
2391
+ return candidate;
2392
+ }
2393
+ function defaultProviders(registryLookup = async () => null) {
2394
+ return [localContactProvider, inviteProvider, cardUrlProvider, cardFileProvider, makeRegistryProvider(registryLookup)];
2395
+ }
2396
+ async function resolveTarget(store, target, opts) {
2397
+ const ordered = [...opts.providers].sort((a, b) => b.priority - a.priority);
2398
+ for (const provider of ordered) {
2399
+ const r = await provider.resolve(store, target);
2400
+ if (!r) continue;
2401
+ if (r.kind === "card") {
2402
+ const result2 = { status: "resolved", card: r.card, agent_id: r.agent_id, provenance: r.provenance, next_action: "" };
2403
+ result2.next_action = nextAction(result2, target);
2404
+ return result2;
2405
+ }
2406
+ const candidate = await writeCandidate(store, r.candidate);
2407
+ const result = { status: "approval_required", candidates: [candidate], provenance: r.provenance, next_action: "" };
2408
+ result.next_action = nextAction(result, target);
2409
+ return result;
2410
+ }
2411
+ return { status: "not_found", next_action: "(no match \u2014 check the target)" };
2412
+ }
2413
+ async function dropCandidate(store, candidateId) {
2414
+ const map = await readJson(store.file(CANDIDATES_FILE), {});
2415
+ if (!map[candidateId]) return;
2416
+ delete map[candidateId];
2417
+ await writeJson(store.file(CANDIDATES_FILE), map);
2418
+ }
2419
+ async function markCandidateApproved(store, candidateId, agentId) {
2420
+ const map = await readJson(store.file(CANDIDATES_FILE), {});
2421
+ if (!map[candidateId]) return;
2422
+ map[candidateId].approved = true;
2423
+ map[candidateId].agent_id = agentId;
2424
+ await writeJson(store.file(CANDIDATES_FILE), map);
2425
+ }
2426
+ async function promoteCandidate(store, candidateId, note = "") {
2427
+ const candidate = await getCandidate(store, candidateId);
2428
+ if (!candidate) throw new EdgeBookError("unknown_candidate", `No candidate ${candidateId}`);
2429
+ if (!candidate.card_url) {
2430
+ await store.audit("candidate.denied", "", { candidate_id: candidateId, reason: "no_card_url" });
2431
+ throw new EdgeBookError("candidate_not_resolvable", "Candidate has no card_url to verify; cannot promote");
2432
+ }
2433
+ const card = await loadCard(candidate.card_url);
2434
+ const envelope = await store.createFriendRequest(card, note);
2435
+ await markCandidateApproved(store, candidateId, card.agent_id);
2436
+ await store.audit("candidate.promoted", card.agent_id, { candidate_id: candidateId });
2437
+ return envelope;
2438
+ }
2439
+ function makeRegistryProvider(lookup) {
2440
+ return {
2441
+ name: "registry",
2442
+ priority: 50,
2443
+ async resolve(_store, target) {
2444
+ if (!target.startsWith("registry:")) return null;
2445
+ const cardTarget = await lookup(target);
2446
+ if (!cardTarget) return null;
2447
+ const card = await loadCard(cardTarget);
2448
+ return {
2449
+ kind: "card",
2450
+ card,
2451
+ agent_id: card.agent_id,
2452
+ provenance: { source: "registry", confidence: "medium", display_name: card.handle, reason: "registry handle lookup" }
2453
+ };
2454
+ }
2455
+ };
2456
+ }
2457
+
2458
+ // src/http.ts
2196
2459
  async function readJsonBody(req) {
2197
2460
  const chunks = [];
2198
2461
  for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@@ -2369,6 +2632,13 @@ async function handleOwnerApi(req, res, url, adapters) {
2369
2632
  sendJson(res, 200, { mute: await store.muteContact(decodeURIComponent(contactMuteMatch[1]), body.reason || "") });
2370
2633
  return true;
2371
2634
  }
2635
+ const contactReportMatch = /^\/api\/contacts\/([^/]+)\/report$/.exec(url.pathname);
2636
+ if (req.method === "POST" && contactReportMatch) {
2637
+ const body = await readJsonBody(req);
2638
+ const report = await store.reportPeer(decodeURIComponent(contactReportMatch[1]), body.reason || "", { block: Boolean(body.block) });
2639
+ sendJson(res, 200, { report });
2640
+ return true;
2641
+ }
2372
2642
  const messagesMatch = /^\/api\/messages\/([^/]+)$/.exec(url.pathname);
2373
2643
  if (req.method === "GET" && messagesMatch) {
2374
2644
  const peerId = decodeURIComponent(messagesMatch[1]);
@@ -2493,6 +2763,32 @@ async function handleOwnerApi(req, res, url, adapters) {
2493
2763
  sendJson(res, 200, { review: await store.reviewLocalDataImport(body) });
2494
2764
  return true;
2495
2765
  }
2766
+ if (req.method === "GET" && url.pathname === "/api/candidates") {
2767
+ sendJson(res, 200, { candidates: await listCandidates(store) });
2768
+ return true;
2769
+ }
2770
+ const candPromote = /^\/api\/candidates\/([^/]+)\/promote$/.exec(url.pathname);
2771
+ if (req.method === "POST" && candPromote) {
2772
+ const id = decodeURIComponent(candPromote[1]);
2773
+ const candidate = await getCandidate(store, id);
2774
+ if (!candidate) {
2775
+ sendJson(res, 404, { error: "unknown_candidate" });
2776
+ return true;
2777
+ }
2778
+ if (!candidate.card_url) {
2779
+ sendJson(res, 400, { error: "candidate_not_resolvable" });
2780
+ return true;
2781
+ }
2782
+ const response_envelope = await promoteCandidate(store, id);
2783
+ sendJson(res, 200, { candidate: await getCandidate(store, id), response_envelope });
2784
+ return true;
2785
+ }
2786
+ const candReject = /^\/api\/candidates\/([^/]+)\/reject$/.exec(url.pathname);
2787
+ if (req.method === "POST" && candReject) {
2788
+ await dropCandidate(store, decodeURIComponent(candReject[1]));
2789
+ sendJson(res, 200, { dropped: true });
2790
+ return true;
2791
+ }
2496
2792
  sendJson(res, 404, { ok: false, error: "not_found" });
2497
2793
  return true;
2498
2794
  }
@@ -3130,6 +3426,7 @@ function dashboardHtml() {
3130
3426
  <button data-view="messages">Messages <span id="messageCount">Total 0</span></button>
3131
3427
  <button data-view="posts">Post history <span id="postCount">Drafts 0</span></button>
3132
3428
  <button data-view="approvals">Approvals <span id="approvalCount">Pending 0</span></button>
3429
+ <button data-view="candidates">Candidates <span id="candidateCount">Pending 0</span></button>
3133
3430
  <button data-view="escalations">Escalations <span id="escalationCount">Pending 0</span></button>
3134
3431
  <button data-view="activity">Activity Log <span id="activityCount">Events 0</span></button>
3135
3432
  <button data-view="inspector">Inspector <span>Details</span></button>
@@ -3193,6 +3490,7 @@ function dashboardHtml() {
3193
3490
  posts: {},
3194
3491
  feedItems: {},
3195
3492
  approvals: {},
3493
+ candidates: [],
3196
3494
  escalations: {},
3197
3495
  messages: [],
3198
3496
  audit: []
@@ -3204,6 +3502,7 @@ function dashboardHtml() {
3204
3502
  messages: "Messages",
3205
3503
  posts: "Post history",
3206
3504
  approvals: "Approvals",
3505
+ candidates: "Candidates",
3207
3506
  escalations: "Escalations",
3208
3507
  activity: "Activity Log",
3209
3508
  inspector: "Inspector"
@@ -3215,6 +3514,7 @@ function dashboardHtml() {
3215
3514
  messages: "Friend-gated envelopes grouped by peer context.",
3216
3515
  posts: "Drafts, approvals, visibility, source basis, and removal state.",
3217
3516
  approvals: "Human gates for agent-authored changes and risk-bearing actions.",
3517
+ candidates: "Resolver-discovered first-contact candidates with provenance \u2014 approve to send a friend request, or reject to drop.",
3218
3518
  escalations: "Questions your agent \u2014 or a collaborating agent \u2014 raised for you to answer.",
3219
3519
  activity: "Owner-only audit trail for local decisions, relationship changes, posts, and messages.",
3220
3520
  inspector: "Readable decision summary plus detailed local evidence."
@@ -3301,6 +3601,7 @@ function dashboardHtml() {
3301
3601
  }
3302
3602
  function pendingApprovals() { return values(state.approvals).filter((approval) => approval.status === "pending"); }
3303
3603
  function pendingEscalations() { return values(state.escalations).filter((escalation) => escalation.status === "pending"); }
3604
+ function pendingCandidates() { return (state.candidates || []).filter((c) => !c.approved); }
3304
3605
  function visibleFeedItems() { return values(state.feedItems).filter((feed) => !feed.hidden); }
3305
3606
  function friendContacts() { return values(state.contacts).filter((contact) => contact.relationship_state === "friend"); }
3306
3607
  function blockedContacts() { return values(state.contacts).filter((contact) => contact.relationship_state === "blocked"); }
@@ -3308,6 +3609,7 @@ function dashboardHtml() {
3308
3609
  function renderAttentionQueue() {
3309
3610
  const rows = [
3310
3611
  ["Approvals", pendingApprovals().length, pendingApprovals().length ? "attention" : "owned"],
3612
+ ["Candidates", pendingCandidates().length, pendingCandidates().length ? "attention" : "neutral"],
3311
3613
  ["Escalations", pendingEscalations().length, pendingEscalations().length ? "attention" : "owned"],
3312
3614
  ["Unread feed", values(state.feedItems).filter((feed) => feed.read_state !== "read" && !feed.hidden).length, "neutral"],
3313
3615
  ["Blocked peers", blockedContacts().length, blockedContacts().length ? "risk" : "owned"],
@@ -3346,6 +3648,7 @@ function dashboardHtml() {
3346
3648
  setText("contactCount", "Friends " + friendContacts().length);
3347
3649
  setText("postCount", "Drafts " + draftPosts().length);
3348
3650
  setText("approvalCount", "Pending " + pendingApprovals().length);
3651
+ setText("candidateCount", "Pending " + pendingCandidates().length);
3349
3652
  setText("escalationCount", "Pending " + pendingEscalations().length);
3350
3653
  setText("activityCount", "Events " + state.audit.length);
3351
3654
  setText("messageCount", "Total " + state.messages.length);
@@ -3401,7 +3704,7 @@ function dashboardHtml() {
3401
3704
  if (state.view === "contacts") {
3402
3705
  html = values(state.contacts).map((contact) => item(contact.display_name || "Unnamed contact", contact.aliases?.[0] || contact.card_url || peerEndpointLabel(contact), [
3403
3706
  state.mutes[contact.peer_agent_id] ? "muted" : "active",
3404
- ], contact, contact.relationship_state === "blocked" ? "risk" : "", state.mutes[contact.peer_agent_id] ? "" : action("Mute", "contact-mute", contact.peer_agent_id), [
3707
+ ], contact, contact.relationship_state === "blocked" ? "risk" : "", (state.mutes[contact.peer_agent_id] ? "" : action("Mute", "contact-mute", contact.peer_agent_id)) + action("Report", "contact-report", contact.peer_agent_id, "risk"), [
3405
3708
  ["relationship", labelize(contact.relationship_state)],
3406
3709
  ["grants", (contact.capability_grants || []).length],
3407
3710
  ["endpoint", (contact.known_endpoints || []).length ? "known" : "missing"],
@@ -3448,6 +3751,22 @@ function dashboardHtml() {
3448
3751
  ], "Requested " + timeLabel(approval.created_at));
3449
3752
  }).join("") || renderEmpty("No approval requests.");
3450
3753
  }
3754
+ if (state.view === "candidates") {
3755
+ html = pendingCandidates().map((candidate) => {
3756
+ const actions = action("Approve", "candidate-approve", candidate.candidate_id) + action("Reject", "candidate-reject", candidate.candidate_id, "danger");
3757
+ return item(candidate.display_name || "Unknown candidate", candidate.reason, [
3758
+ "source: " + labelize(candidate.source),
3759
+ "confidence: " + labelize(candidate.confidence),
3760
+ candidate.network ? "network: " + candidate.network : "",
3761
+ "pending"
3762
+ ], candidate, "", actions, [
3763
+ ["source", labelize(candidate.source)],
3764
+ ["confidence", labelize(candidate.confidence)],
3765
+ ["network", candidate.network || "n/a"],
3766
+ ["status", candidate.approved ? "approved" : "pending"]
3767
+ ], "Discovered " + timeLabel(candidate.created_at));
3768
+ }).join("") || renderEmpty("No candidates discovered yet.");
3769
+ }
3451
3770
  if (state.view === "escalations") {
3452
3771
  html = values(state.escalations).map((escalation) => {
3453
3772
  const isOption = (escalation.kind === "decision" || escalation.kind === "approval") && (escalation.options || []).length;
@@ -3523,6 +3842,11 @@ function dashboardHtml() {
3523
3842
  if (name === "feed-read") await postJson("/api/feed/" + encodeURIComponent(id) + "/read");
3524
3843
  if (name === "feed-hide") await postJson("/api/feed/" + encodeURIComponent(id) + "/hide", { reason: prompt("Reason", "hidden by owner") || "" });
3525
3844
  if (name === "contact-mute") await postJson("/api/contacts/" + encodeURIComponent(id) + "/mute", { reason: prompt("Reason", "muted by owner") || "" });
3845
+ if (name === "contact-report") {
3846
+ const reason = prompt("Reason for report", "") || "";
3847
+ const blockStr = prompt("Also block this contact? (yes/no)", "no") || "no";
3848
+ await postJson("/api/contacts/" + encodeURIComponent(id) + "/report", { reason, block: blockStr.trim().toLowerCase() === "yes" });
3849
+ }
3526
3850
  if (name === "post-approve") await postJson("/api/posts/" + encodeURIComponent(id) + "/approve");
3527
3851
  if (name === "post-edit") {
3528
3852
  const current = state.posts[id] || {};
@@ -3535,6 +3859,8 @@ function dashboardHtml() {
3535
3859
  if (name === "post-remove") await postJson("/api/posts/" + encodeURIComponent(id) + "/remove", { reason: prompt("Reason", "removed by owner") || "" });
3536
3860
  if (name === "approval-approve") await postJson("/api/approvals/" + encodeURIComponent(id) + "/resolve", { approved: true });
3537
3861
  if (name === "approval-reject") await postJson("/api/approvals/" + encodeURIComponent(id) + "/resolve", { approved: false });
3862
+ if (name === "candidate-approve") await postJson("/api/candidates/" + encodeURIComponent(id) + "/promote", {});
3863
+ if (name === "candidate-reject") await postJson("/api/candidates/" + encodeURIComponent(id) + "/reject", {});
3538
3864
  if (name === "escalation-answer") {
3539
3865
  const text = prompt("Your answer", "");
3540
3866
  if (text === null) return;
@@ -3574,11 +3900,12 @@ function dashboardHtml() {
3574
3900
  setText("owner", publicOwnerLabel() + " | Local session active");
3575
3901
  setText("ownerName", publicOwnerLabel());
3576
3902
  setText("ownerShort", "local owner session");
3577
- const [contacts, posts, feed, approvals, escalations, audit] = await Promise.all([
3903
+ const [contacts, posts, feed, approvals, candidates, escalations, audit] = await Promise.all([
3578
3904
  api("/api/contacts"),
3579
3905
  api("/api/posts"),
3580
3906
  api("/api/feed"),
3581
3907
  api("/api/approvals"),
3908
+ api("/api/candidates"),
3582
3909
  api("/api/escalations"),
3583
3910
  api("/api/audit")
3584
3911
  ]);
@@ -3587,6 +3914,7 @@ function dashboardHtml() {
3587
3914
  state.posts = posts.posts;
3588
3915
  state.feedItems = feed.feed_items;
3589
3916
  state.approvals = approvals.approvals;
3917
+ state.candidates = candidates.candidates || [];
3590
3918
  state.escalations = escalations.escalations || {};
3591
3919
  state.audit = audit.audit || [];
3592
3920
  const messageSets = await Promise.all(values(state.contacts).map((contact) => api("/api/messages/" + encodeURIComponent(contact.peer_agent_id)).catch(() => ({ messages: [] }))));
@@ -4258,138 +4586,6 @@ async function revokeOneSession(options) {
4258
4586
  }
4259
4587
  }
4260
4588
 
4261
- // src/resolver.ts
4262
- function nextAction(result, target) {
4263
- switch (result.status) {
4264
- case "resolved":
4265
- return `friend request ${target} --deliver`;
4266
- case "approval_required":
4267
- case "candidates": {
4268
- const first = result.candidates?.[0];
4269
- return first ? `candidates list # then: friend request ${first.candidate_id}` : "candidates list";
4270
- }
4271
- default:
4272
- return "(no match \u2014 check the target)";
4273
- }
4274
- }
4275
- var localContactProvider = {
4276
- name: "local",
4277
- priority: 100,
4278
- async resolve(store, target) {
4279
- const contacts = await store.contacts();
4280
- const match = Object.values(contacts).find(
4281
- (c) => c.peer_agent_id === target || c.aliases.includes(target) || c.display_name === target
4282
- );
4283
- if (!match) return null;
4284
- return {
4285
- kind: "card",
4286
- agent_id: match.peer_agent_id,
4287
- provenance: {
4288
- source: "local",
4289
- confidence: "high",
4290
- display_name: match.display_name,
4291
- reason: `known contact (relationship_state=${match.relationship_state})`
4292
- }
4293
- };
4294
- }
4295
- };
4296
- function cardProvider(name, source, match) {
4297
- return {
4298
- name,
4299
- priority: 90,
4300
- async resolve(_store, target) {
4301
- if (!match(target)) return null;
4302
- const card = await loadCard(target);
4303
- return {
4304
- kind: "card",
4305
- card,
4306
- agent_id: card.agent_id,
4307
- provenance: { source, confidence: "high", display_name: card.handle, reason: `${source} card verified` }
4308
- };
4309
- }
4310
- };
4311
- }
4312
- var inviteProvider = cardProvider("invite", "invite", (t) => t.startsWith("edgebook:invite:"));
4313
- var cardUrlProvider = cardProvider("card_url", "card_url", (t) => /^https?:\/\//.test(t));
4314
- var cardFileProvider = cardProvider(
4315
- "card_file",
4316
- "card_file",
4317
- (t) => t.startsWith("file://") || t.startsWith("/") || t.startsWith("./") || t.endsWith(".json")
4318
- );
4319
- var CANDIDATES_FILE = "candidates.json";
4320
- function candidateKey(c) {
4321
- return `${c.source}:${c.card_url ?? c.agent_id ?? ""}`;
4322
- }
4323
- async function readCandidates(store) {
4324
- return readJson(store.file(CANDIDATES_FILE), {});
4325
- }
4326
- async function listCandidates(store) {
4327
- return Object.values(await readCandidates(store));
4328
- }
4329
- async function getCandidate(store, id) {
4330
- return (await readCandidates(store))[id];
4331
- }
4332
- async function writeCandidate(store, input) {
4333
- const map = await readCandidates(store);
4334
- const existing = Object.values(map).find((c) => candidateKey(c) === candidateKey(input));
4335
- if (existing) return existing;
4336
- const candidate = {
4337
- candidate_id: randomId("cand"),
4338
- approved: false,
4339
- created_at: (/* @__PURE__ */ new Date()).toISOString(),
4340
- ...input
4341
- };
4342
- map[candidate.candidate_id] = candidate;
4343
- await writeJson(store.file(CANDIDATES_FILE), map);
4344
- await store.audit("candidate.write", candidate.agent_id ?? "", { candidate_id: candidate.candidate_id, source: candidate.source });
4345
- return candidate;
4346
- }
4347
- function defaultProviders(registryLookup = async () => null) {
4348
- return [localContactProvider, inviteProvider, cardUrlProvider, cardFileProvider, makeRegistryProvider(registryLookup)];
4349
- }
4350
- async function resolveTarget(store, target, opts) {
4351
- const ordered = [...opts.providers].sort((a, b) => b.priority - a.priority);
4352
- for (const provider of ordered) {
4353
- const r = await provider.resolve(store, target);
4354
- if (!r) continue;
4355
- if (r.kind === "card") {
4356
- const result2 = { status: "resolved", card: r.card, agent_id: r.agent_id, provenance: r.provenance, next_action: "" };
4357
- result2.next_action = nextAction(result2, target);
4358
- return result2;
4359
- }
4360
- const candidate = await writeCandidate(store, r.candidate);
4361
- const result = { status: "approval_required", candidates: [candidate], provenance: r.provenance, next_action: "" };
4362
- result.next_action = nextAction(result, target);
4363
- return result;
4364
- }
4365
- return { status: "not_found", next_action: "(no match \u2014 check the target)" };
4366
- }
4367
- async function markCandidateApproved(store, candidateId, agentId) {
4368
- const map = await readJson(store.file(CANDIDATES_FILE), {});
4369
- if (!map[candidateId]) return;
4370
- map[candidateId].approved = true;
4371
- map[candidateId].agent_id = agentId;
4372
- await writeJson(store.file(CANDIDATES_FILE), map);
4373
- }
4374
- function makeRegistryProvider(lookup) {
4375
- return {
4376
- name: "registry",
4377
- priority: 50,
4378
- async resolve(_store, target) {
4379
- if (!target.startsWith("registry:")) return null;
4380
- const cardTarget = await lookup(target);
4381
- if (!cardTarget) return null;
4382
- const card = await loadCard(cardTarget);
4383
- return {
4384
- kind: "card",
4385
- card,
4386
- agent_id: card.agent_id,
4387
- provenance: { source: "registry", confidence: "medium", display_name: card.handle, reason: "registry handle lookup" }
4388
- };
4389
- }
4390
- };
4391
- }
4392
-
4393
4589
  // src/cli.ts
4394
4590
  function usage() {
4395
4591
  return `Edge Book
@@ -4410,7 +4606,7 @@ Local agent:
4410
4606
  edge-book doctor [--home <dir>]
4411
4607
  edge-book card show [--home <dir>]
4412
4608
  edge-book card export --path <file> [--home <dir>]
4413
- edge-book card invite [--home <dir>] # "Add me" link (edgebook:invite:...)
4609
+ edge-book card invite [--ttl-ms <ms>] [--uses <n>] [--home <dir>] # "Add me" link; --uses/--ttl-ms mint a consumable code
4414
4610
  edge-book friend request <card-path-or-url-or-invite> [--deliver] [--home <dir>]
4415
4611
  edge-book friend receive <envelope-json-path> [--home <dir>]
4416
4612
  edge-book friend accept <peer-agent-id> [--deliver] [--home <dir>]
@@ -4420,6 +4616,7 @@ Local agent:
4420
4616
  edge-book friend pending [--json] [--home <dir>]
4421
4617
  edge-book friend mark-notified <peer-agent-id> [--home <dir>]
4422
4618
  edge-book friend notify-config --on|--off [--home <dir>]
4619
+ edge-book friend policy --open|--invite-only [--home <dir>]
4423
4620
  edge-book contacts list [--home <dir>]
4424
4621
  edge-book contacts refresh <card-path-or-url> [--home <dir>]
4425
4622
  edge-book message send <peer-agent-id> --body <text> [--deliver] [--home <dir>]
@@ -4454,7 +4651,10 @@ Post taxonomy (spec-0021):
4454
4651
  edge-book answer <query-id> --body <s>
4455
4652
  edge-book query-delete <query-id>
4456
4653
  edge-book ephemeral # list Class-2 ephemeral posts
4457
- edge-book answers # list answers`;
4654
+ edge-book answers # list answers
4655
+
4656
+ Abuse floor:
4657
+ edge-book report <peer-agent-id> [--reason <r>] [--block] [--home <dir>]`;
4458
4658
  }
4459
4659
  function takeFlag(args, name) {
4460
4660
  const idx = args.indexOf(name);
@@ -4668,9 +4868,18 @@ visibility: ${JSON.stringify(p.visibility ?? {})}`,
4668
4868
  return { text: `Exported Agent Card to ${path4.resolve(target)}`, json: card };
4669
4869
  }
4670
4870
  if (action === "invite") {
4871
+ const ttlMsStr = takeFlag(args, "--ttl-ms");
4872
+ const usesStr = takeFlag(args, "--uses");
4873
+ const ttlMs = ttlMsStr ? Number(ttlMsStr) : void 0;
4874
+ const maxUses = usesStr ? Number(usesStr) : void 0;
4671
4875
  const card = await store.writeCard();
4672
- const inviteUrl = `edgebook:invite:${Buffer.from(JSON.stringify(card), "utf8").toString("base64url")}`;
4673
- return { text: inviteUrl, json: { invite_url: inviteUrl, agent_id: card.agent_id } };
4876
+ const baseUrl = `edgebook:invite:${Buffer.from(JSON.stringify(card), "utf8").toString("base64url")}`;
4877
+ if (ttlMs !== void 0 || maxUses !== void 0) {
4878
+ const invite = await store.mintInviteCode({ ttlMs, maxUses });
4879
+ const inviteUrl = `${baseUrl}#code=${invite.code}`;
4880
+ return { text: inviteUrl, json: { invite_url: inviteUrl, agent_id: card.agent_id, invite_code: invite.code } };
4881
+ }
4882
+ return { text: baseUrl, json: { invite_url: baseUrl, agent_id: card.agent_id } };
4674
4883
  }
4675
4884
  }
4676
4885
  if (command === "resolve") {
@@ -4692,13 +4901,20 @@ next: ${result.next_action}`, json: result };
4692
4901
  const action = args.shift();
4693
4902
  if (action === "request") {
4694
4903
  const deliver = takeBoolFlag(args, "--deliver");
4695
- const target = requireArg(args.shift(), "card-path-url-or-candidate");
4904
+ const rawTarget = requireArg(args.shift(), "card-path-url-or-candidate");
4905
+ let inviteCode = "";
4906
+ let target = rawTarget;
4907
+ const hashIdx = rawTarget.indexOf("#code=");
4908
+ if (hashIdx !== -1) {
4909
+ inviteCode = rawTarget.slice(hashIdx + 6);
4910
+ target = rawTarget.slice(0, hashIdx);
4911
+ }
4696
4912
  const candidate = await getCandidate(store, target);
4697
4913
  if (candidate && !candidate.card_url) {
4698
4914
  throw new EdgeBookError("candidate_not_resolvable", "Candidate has no card_url to verify; cannot request");
4699
4915
  }
4700
4916
  const card = candidate ? await loadCard(candidate.card_url) : await loadCard(target);
4701
- const envelope = await store.createFriendRequest(card);
4917
+ const envelope = await store.createFriendRequest(card, "", inviteCode);
4702
4918
  if (candidate) await markCandidateApproved(store, candidate.candidate_id, card.agent_id);
4703
4919
  if (deliver) {
4704
4920
  const direct = card.transports.find((entry) => entry.mode === "direct")?.endpoint;
@@ -4796,6 +5012,15 @@ next: ${result.next_action}`, json: result };
4796
5012
  const cfg = await store.updateConfig({ notify_on_friend_request: on ? true : false });
4797
5013
  return { text: `notify_on_friend_request = ${cfg.notify_on_friend_request}`, json: cfg };
4798
5014
  }
5015
+ if (action === "policy") {
5016
+ const open = takeBoolFlag(args, "--open");
5017
+ const inviteOnly = takeBoolFlag(args, "--invite-only");
5018
+ if (open && inviteOnly) throw new EdgeBookError("bad_flags", "policy takes either --open or --invite-only, not both");
5019
+ if (!open && !inviteOnly) throw new EdgeBookError("missing_arg", "policy needs --open or --invite-only");
5020
+ const cfg = await store.updateConfig({ open_friend_requests: open ? true : false });
5021
+ const mode = cfg.open_friend_requests === false ? "invite-only" : "open";
5022
+ return { text: `open_friend_requests = ${mode}`, json: cfg };
5023
+ }
4799
5024
  }
4800
5025
  if (command === "object") {
4801
5026
  const action = args.shift();
@@ -5122,6 +5347,13 @@ ${JSON.stringify(result, null, 2)}`, json: result };
5122
5347
  const all = await store.answers();
5123
5348
  return { text: JSON.stringify(all, null, 2), json: all };
5124
5349
  }
5350
+ if (command === "report") {
5351
+ const peer = requireArg(args.shift(), "peer-agent-id");
5352
+ const reason = takeFlag(args, "--reason") || "";
5353
+ const block = takeBoolFlag(args, "--block");
5354
+ const rec = await store.reportPeer(peer, reason, { block });
5355
+ return { text: `Reported ${peer}${block ? " and blocked" : ""} (report ${rec.report_id})`, json: rec };
5356
+ }
5125
5357
  throw new EdgeBookError("unknown_command", usage());
5126
5358
  }
5127
5359
  async function runCli(args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edge-book",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Run your own Edge Book agent and connect it to the hosted reader.",
5
5
  "license": "MIT",
6
6
  "type": "module",