edge-book 0.8.0 → 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 +405 -167
  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";
@@ -175,6 +178,15 @@ function resolveFieldVisibility(profile, field) {
175
178
  function resolveSocialVisibility(profile, label) {
176
179
  return profile.visibility?.[label] ?? profile.visibility?.["*"] ?? "friends";
177
180
  }
181
+ function projectProfileFields(profile, includeField) {
182
+ const out = {};
183
+ if (profile.name && includeField(resolveFieldVisibility(profile, "name"))) out.name = profile.name;
184
+ if (profile.bio && includeField(resolveFieldVisibility(profile, "bio"))) out.bio = profile.bio;
185
+ if (profile.location && includeField(resolveFieldVisibility(profile, "location"))) out.location = profile.location;
186
+ const socials = (profile.socials ?? []).filter((s) => includeField(resolveSocialVisibility(profile, s.label)));
187
+ if (socials.length) out.socials = socials;
188
+ return out;
189
+ }
178
190
  function computeLifecycle(expiresAt, hard, current) {
179
191
  if (current === "expired" || current === "cancelled" || current === "tombstoned") {
180
192
  return current;
@@ -276,6 +288,10 @@ var EdgeBookStore = class {
276
288
  if (input.direct_url !== void 0) next.direct_url = input.direct_url;
277
289
  if (input.relay_url !== void 0) next.relay_url = input.relay_url;
278
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;
279
295
  await writeJson(this.file(CONFIG_FILE), next);
280
296
  return next;
281
297
  }
@@ -287,15 +303,9 @@ var EdgeBookStore = class {
287
303
  if (config.relay_url) transports.push({ mode: "relay", endpoint: config.relay_url });
288
304
  const caps = Object.values(await this.capabilities()).map((c) => ({ name: c.name, version: c.version, summary: c.summary, status: c.status }));
289
305
  const prof = defaultProfile(identity);
290
- const pubInclude = (field) => resolveFieldVisibility(prof, field) === "public";
291
- const pubSocials = (prof.socials ?? []).filter((s) => resolveSocialVisibility(prof, s.label) === "public");
292
- const publicProfile = {
293
- ...prof.name && pubInclude("name") ? { name: prof.name } : {},
294
- ...prof.bio && pubInclude("bio") ? { bio: prof.bio } : {},
295
- ...prof.location && pubInclude("location") ? { location: prof.location } : {},
296
- ...pubSocials.length ? { socials: pubSocials } : {}
297
- };
298
- const publicName = prof.name && pubInclude("name") ? prof.name : void 0;
306
+ const publicFields = projectProfileFields(prof, (v) => v === "public");
307
+ const publicProfile = { ...publicFields };
308
+ const publicName = publicFields.name;
299
309
  const unsigned = {
300
310
  schema: "openclaw-agent-card/0.1",
301
311
  agent_id: identity.agent_id,
@@ -326,18 +336,12 @@ var EdgeBookStore = class {
326
336
  async buildFriendProfile() {
327
337
  const identity = await this.identity();
328
338
  const profile = defaultProfile(identity);
329
- const include = (field) => resolveFieldVisibility(profile, field) !== "off";
330
- const socials = (profile.socials ?? []).filter(
331
- (s) => resolveSocialVisibility(profile, s.label) !== "off"
332
- );
339
+ const friendFields = projectProfileFields(profile, (v) => v !== "off");
333
340
  const unsigned = {
334
341
  schema: "openclaw-friend-profile/0.1",
335
342
  agent_id: identity.agent_id,
336
343
  profile_version: profile.profile_version ?? 1,
337
- ...profile.name && include("name") ? { name: profile.name } : {},
338
- ...profile.bio && include("bio") ? { bio: profile.bio } : {},
339
- ...profile.location && include("location") ? { location: profile.location } : {},
340
- ...socials.length ? { socials } : {},
344
+ ...friendFields,
341
345
  issued_at: now()
342
346
  };
343
347
  return { ...unsigned, signature: signPayload(unsigned, identity.private_key_pem) };
@@ -462,7 +466,7 @@ var EdgeBookStore = class {
462
466
  await this.audit(`relationship.${type}`, peerAgentId, { previous, next: nextState, reason });
463
467
  return event;
464
468
  }
465
- async createFriendRequest(targetCard, note = "") {
469
+ async createFriendRequest(targetCard, note = "", inviteCode = "") {
466
470
  const identity = await this.identity();
467
471
  validateCard(targetCard);
468
472
  const existing = (await this.contacts())[targetCard.agent_id];
@@ -470,6 +474,7 @@ var EdgeBookStore = class {
470
474
  await this.upsertContactFromCard(targetCard, "request_sent");
471
475
  await this.setRelationship(targetCard.agent_id, "request_sent", "FriendRequest", note);
472
476
  const card = await this.writeCard();
477
+ const body = { card, note, ...inviteCode ? { invite_code: inviteCode } : {} };
473
478
  return this.signEnvelope({
474
479
  type: "friend_request",
475
480
  to_agent_id: targetCard.agent_id,
@@ -477,15 +482,56 @@ var EdgeBookStore = class {
477
482
  capability_id: "",
478
483
  ref: "",
479
484
  transport: "local",
480
- body: { card, note }
485
+ body
481
486
  });
482
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
+ }
483
519
  async receiveFriendRequest(envelope) {
484
520
  await this.verifyEnvelope(envelope);
485
521
  if (envelope.type !== "friend_request") throw new EdgeBookError("wrong_message_type", "Expected friend_request envelope");
522
+ await this.enforceInboundRate(envelope.from_agent_id);
486
523
  const body = envelope.body;
487
524
  validateCard(body.card);
488
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
+ }
489
535
  const contact = await this.upsertContactFromCard(body.card, "request_received");
490
536
  await this.setRelationship(envelope.from_agent_id, "request_received", "FriendRequest", body.note);
491
537
  await appendJsonl(this.file(INBOX_FILE), envelope);
@@ -661,6 +707,66 @@ var EdgeBookStore = class {
661
707
  async block(peerAgentId) {
662
708
  await this.setRelationship(peerAgentId, "blocked", "Block", "blocked");
663
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
+ }
664
770
  async issueGrant(subjectAgentId, scopes, expiresAt = "") {
665
771
  const identity = await this.identity();
666
772
  const unsigned = {
@@ -1248,6 +1354,7 @@ var EdgeBookStore = class {
1248
1354
  async receiveObjectShare(envelope) {
1249
1355
  await this.verifyEnvelope(envelope);
1250
1356
  if (envelope.type !== "object_share") throw new EdgeBookError("wrong_message_type", "Expected object_share envelope");
1357
+ await this.enforceInboundRate(envelope.from_agent_id);
1251
1358
  const identity = await this.identity();
1252
1359
  const body = envelope.body;
1253
1360
  const { object, grant } = body;
@@ -2196,6 +2303,159 @@ async function runTwoAgentHarness(baseDir) {
2196
2303
  import fs2 from "fs/promises";
2197
2304
  import http from "http";
2198
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
2199
2459
  async function readJsonBody(req) {
2200
2460
  const chunks = [];
2201
2461
  for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
@@ -2372,6 +2632,13 @@ async function handleOwnerApi(req, res, url, adapters) {
2372
2632
  sendJson(res, 200, { mute: await store.muteContact(decodeURIComponent(contactMuteMatch[1]), body.reason || "") });
2373
2633
  return true;
2374
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
+ }
2375
2642
  const messagesMatch = /^\/api\/messages\/([^/]+)$/.exec(url.pathname);
2376
2643
  if (req.method === "GET" && messagesMatch) {
2377
2644
  const peerId = decodeURIComponent(messagesMatch[1]);
@@ -2496,6 +2763,32 @@ async function handleOwnerApi(req, res, url, adapters) {
2496
2763
  sendJson(res, 200, { review: await store.reviewLocalDataImport(body) });
2497
2764
  return true;
2498
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
+ }
2499
2792
  sendJson(res, 404, { ok: false, error: "not_found" });
2500
2793
  return true;
2501
2794
  }
@@ -3133,6 +3426,7 @@ function dashboardHtml() {
3133
3426
  <button data-view="messages">Messages <span id="messageCount">Total 0</span></button>
3134
3427
  <button data-view="posts">Post history <span id="postCount">Drafts 0</span></button>
3135
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>
3136
3430
  <button data-view="escalations">Escalations <span id="escalationCount">Pending 0</span></button>
3137
3431
  <button data-view="activity">Activity Log <span id="activityCount">Events 0</span></button>
3138
3432
  <button data-view="inspector">Inspector <span>Details</span></button>
@@ -3196,6 +3490,7 @@ function dashboardHtml() {
3196
3490
  posts: {},
3197
3491
  feedItems: {},
3198
3492
  approvals: {},
3493
+ candidates: [],
3199
3494
  escalations: {},
3200
3495
  messages: [],
3201
3496
  audit: []
@@ -3207,6 +3502,7 @@ function dashboardHtml() {
3207
3502
  messages: "Messages",
3208
3503
  posts: "Post history",
3209
3504
  approvals: "Approvals",
3505
+ candidates: "Candidates",
3210
3506
  escalations: "Escalations",
3211
3507
  activity: "Activity Log",
3212
3508
  inspector: "Inspector"
@@ -3218,6 +3514,7 @@ function dashboardHtml() {
3218
3514
  messages: "Friend-gated envelopes grouped by peer context.",
3219
3515
  posts: "Drafts, approvals, visibility, source basis, and removal state.",
3220
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.",
3221
3518
  escalations: "Questions your agent \u2014 or a collaborating agent \u2014 raised for you to answer.",
3222
3519
  activity: "Owner-only audit trail for local decisions, relationship changes, posts, and messages.",
3223
3520
  inspector: "Readable decision summary plus detailed local evidence."
@@ -3304,6 +3601,7 @@ function dashboardHtml() {
3304
3601
  }
3305
3602
  function pendingApprovals() { return values(state.approvals).filter((approval) => approval.status === "pending"); }
3306
3603
  function pendingEscalations() { return values(state.escalations).filter((escalation) => escalation.status === "pending"); }
3604
+ function pendingCandidates() { return (state.candidates || []).filter((c) => !c.approved); }
3307
3605
  function visibleFeedItems() { return values(state.feedItems).filter((feed) => !feed.hidden); }
3308
3606
  function friendContacts() { return values(state.contacts).filter((contact) => contact.relationship_state === "friend"); }
3309
3607
  function blockedContacts() { return values(state.contacts).filter((contact) => contact.relationship_state === "blocked"); }
@@ -3311,6 +3609,7 @@ function dashboardHtml() {
3311
3609
  function renderAttentionQueue() {
3312
3610
  const rows = [
3313
3611
  ["Approvals", pendingApprovals().length, pendingApprovals().length ? "attention" : "owned"],
3612
+ ["Candidates", pendingCandidates().length, pendingCandidates().length ? "attention" : "neutral"],
3314
3613
  ["Escalations", pendingEscalations().length, pendingEscalations().length ? "attention" : "owned"],
3315
3614
  ["Unread feed", values(state.feedItems).filter((feed) => feed.read_state !== "read" && !feed.hidden).length, "neutral"],
3316
3615
  ["Blocked peers", blockedContacts().length, blockedContacts().length ? "risk" : "owned"],
@@ -3349,6 +3648,7 @@ function dashboardHtml() {
3349
3648
  setText("contactCount", "Friends " + friendContacts().length);
3350
3649
  setText("postCount", "Drafts " + draftPosts().length);
3351
3650
  setText("approvalCount", "Pending " + pendingApprovals().length);
3651
+ setText("candidateCount", "Pending " + pendingCandidates().length);
3352
3652
  setText("escalationCount", "Pending " + pendingEscalations().length);
3353
3653
  setText("activityCount", "Events " + state.audit.length);
3354
3654
  setText("messageCount", "Total " + state.messages.length);
@@ -3404,7 +3704,7 @@ function dashboardHtml() {
3404
3704
  if (state.view === "contacts") {
3405
3705
  html = values(state.contacts).map((contact) => item(contact.display_name || "Unnamed contact", contact.aliases?.[0] || contact.card_url || peerEndpointLabel(contact), [
3406
3706
  state.mutes[contact.peer_agent_id] ? "muted" : "active",
3407
- ], 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"), [
3408
3708
  ["relationship", labelize(contact.relationship_state)],
3409
3709
  ["grants", (contact.capability_grants || []).length],
3410
3710
  ["endpoint", (contact.known_endpoints || []).length ? "known" : "missing"],
@@ -3451,6 +3751,22 @@ function dashboardHtml() {
3451
3751
  ], "Requested " + timeLabel(approval.created_at));
3452
3752
  }).join("") || renderEmpty("No approval requests.");
3453
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
+ }
3454
3770
  if (state.view === "escalations") {
3455
3771
  html = values(state.escalations).map((escalation) => {
3456
3772
  const isOption = (escalation.kind === "decision" || escalation.kind === "approval") && (escalation.options || []).length;
@@ -3526,6 +3842,11 @@ function dashboardHtml() {
3526
3842
  if (name === "feed-read") await postJson("/api/feed/" + encodeURIComponent(id) + "/read");
3527
3843
  if (name === "feed-hide") await postJson("/api/feed/" + encodeURIComponent(id) + "/hide", { reason: prompt("Reason", "hidden by owner") || "" });
3528
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
+ }
3529
3850
  if (name === "post-approve") await postJson("/api/posts/" + encodeURIComponent(id) + "/approve");
3530
3851
  if (name === "post-edit") {
3531
3852
  const current = state.posts[id] || {};
@@ -3538,6 +3859,8 @@ function dashboardHtml() {
3538
3859
  if (name === "post-remove") await postJson("/api/posts/" + encodeURIComponent(id) + "/remove", { reason: prompt("Reason", "removed by owner") || "" });
3539
3860
  if (name === "approval-approve") await postJson("/api/approvals/" + encodeURIComponent(id) + "/resolve", { approved: true });
3540
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", {});
3541
3864
  if (name === "escalation-answer") {
3542
3865
  const text = prompt("Your answer", "");
3543
3866
  if (text === null) return;
@@ -3577,11 +3900,12 @@ function dashboardHtml() {
3577
3900
  setText("owner", publicOwnerLabel() + " | Local session active");
3578
3901
  setText("ownerName", publicOwnerLabel());
3579
3902
  setText("ownerShort", "local owner session");
3580
- const [contacts, posts, feed, approvals, escalations, audit] = await Promise.all([
3903
+ const [contacts, posts, feed, approvals, candidates, escalations, audit] = await Promise.all([
3581
3904
  api("/api/contacts"),
3582
3905
  api("/api/posts"),
3583
3906
  api("/api/feed"),
3584
3907
  api("/api/approvals"),
3908
+ api("/api/candidates"),
3585
3909
  api("/api/escalations"),
3586
3910
  api("/api/audit")
3587
3911
  ]);
@@ -3590,6 +3914,7 @@ function dashboardHtml() {
3590
3914
  state.posts = posts.posts;
3591
3915
  state.feedItems = feed.feed_items;
3592
3916
  state.approvals = approvals.approvals;
3917
+ state.candidates = candidates.candidates || [];
3593
3918
  state.escalations = escalations.escalations || {};
3594
3919
  state.audit = audit.audit || [];
3595
3920
  const messageSets = await Promise.all(values(state.contacts).map((contact) => api("/api/messages/" + encodeURIComponent(contact.peer_agent_id)).catch(() => ({ messages: [] }))));
@@ -4261,138 +4586,6 @@ async function revokeOneSession(options) {
4261
4586
  }
4262
4587
  }
4263
4588
 
4264
- // src/resolver.ts
4265
- function nextAction(result, target) {
4266
- switch (result.status) {
4267
- case "resolved":
4268
- return `friend request ${target} --deliver`;
4269
- case "approval_required":
4270
- case "candidates": {
4271
- const first = result.candidates?.[0];
4272
- return first ? `candidates list # then: friend request ${first.candidate_id}` : "candidates list";
4273
- }
4274
- default:
4275
- return "(no match \u2014 check the target)";
4276
- }
4277
- }
4278
- var localContactProvider = {
4279
- name: "local",
4280
- priority: 100,
4281
- async resolve(store, target) {
4282
- const contacts = await store.contacts();
4283
- const match = Object.values(contacts).find(
4284
- (c) => c.peer_agent_id === target || c.aliases.includes(target) || c.display_name === target
4285
- );
4286
- if (!match) return null;
4287
- return {
4288
- kind: "card",
4289
- agent_id: match.peer_agent_id,
4290
- provenance: {
4291
- source: "local",
4292
- confidence: "high",
4293
- display_name: match.display_name,
4294
- reason: `known contact (relationship_state=${match.relationship_state})`
4295
- }
4296
- };
4297
- }
4298
- };
4299
- function cardProvider(name, source, match) {
4300
- return {
4301
- name,
4302
- priority: 90,
4303
- async resolve(_store, target) {
4304
- if (!match(target)) return null;
4305
- const card = await loadCard(target);
4306
- return {
4307
- kind: "card",
4308
- card,
4309
- agent_id: card.agent_id,
4310
- provenance: { source, confidence: "high", display_name: card.handle, reason: `${source} card verified` }
4311
- };
4312
- }
4313
- };
4314
- }
4315
- var inviteProvider = cardProvider("invite", "invite", (t) => t.startsWith("edgebook:invite:"));
4316
- var cardUrlProvider = cardProvider("card_url", "card_url", (t) => /^https?:\/\//.test(t));
4317
- var cardFileProvider = cardProvider(
4318
- "card_file",
4319
- "card_file",
4320
- (t) => t.startsWith("file://") || t.startsWith("/") || t.startsWith("./") || t.endsWith(".json")
4321
- );
4322
- var CANDIDATES_FILE = "candidates.json";
4323
- function candidateKey(c) {
4324
- return `${c.source}:${c.card_url ?? c.agent_id ?? ""}`;
4325
- }
4326
- async function readCandidates(store) {
4327
- return readJson(store.file(CANDIDATES_FILE), {});
4328
- }
4329
- async function listCandidates(store) {
4330
- return Object.values(await readCandidates(store));
4331
- }
4332
- async function getCandidate(store, id) {
4333
- return (await readCandidates(store))[id];
4334
- }
4335
- async function writeCandidate(store, input) {
4336
- const map = await readCandidates(store);
4337
- const existing = Object.values(map).find((c) => candidateKey(c) === candidateKey(input));
4338
- if (existing) return existing;
4339
- const candidate = {
4340
- candidate_id: randomId("cand"),
4341
- approved: false,
4342
- created_at: (/* @__PURE__ */ new Date()).toISOString(),
4343
- ...input
4344
- };
4345
- map[candidate.candidate_id] = candidate;
4346
- await writeJson(store.file(CANDIDATES_FILE), map);
4347
- await store.audit("candidate.write", candidate.agent_id ?? "", { candidate_id: candidate.candidate_id, source: candidate.source });
4348
- return candidate;
4349
- }
4350
- function defaultProviders(registryLookup = async () => null) {
4351
- return [localContactProvider, inviteProvider, cardUrlProvider, cardFileProvider, makeRegistryProvider(registryLookup)];
4352
- }
4353
- async function resolveTarget(store, target, opts) {
4354
- const ordered = [...opts.providers].sort((a, b) => b.priority - a.priority);
4355
- for (const provider of ordered) {
4356
- const r = await provider.resolve(store, target);
4357
- if (!r) continue;
4358
- if (r.kind === "card") {
4359
- const result2 = { status: "resolved", card: r.card, agent_id: r.agent_id, provenance: r.provenance, next_action: "" };
4360
- result2.next_action = nextAction(result2, target);
4361
- return result2;
4362
- }
4363
- const candidate = await writeCandidate(store, r.candidate);
4364
- const result = { status: "approval_required", candidates: [candidate], provenance: r.provenance, next_action: "" };
4365
- result.next_action = nextAction(result, target);
4366
- return result;
4367
- }
4368
- return { status: "not_found", next_action: "(no match \u2014 check the target)" };
4369
- }
4370
- async function markCandidateApproved(store, candidateId, agentId) {
4371
- const map = await readJson(store.file(CANDIDATES_FILE), {});
4372
- if (!map[candidateId]) return;
4373
- map[candidateId].approved = true;
4374
- map[candidateId].agent_id = agentId;
4375
- await writeJson(store.file(CANDIDATES_FILE), map);
4376
- }
4377
- function makeRegistryProvider(lookup) {
4378
- return {
4379
- name: "registry",
4380
- priority: 50,
4381
- async resolve(_store, target) {
4382
- if (!target.startsWith("registry:")) return null;
4383
- const cardTarget = await lookup(target);
4384
- if (!cardTarget) return null;
4385
- const card = await loadCard(cardTarget);
4386
- return {
4387
- kind: "card",
4388
- card,
4389
- agent_id: card.agent_id,
4390
- provenance: { source: "registry", confidence: "medium", display_name: card.handle, reason: "registry handle lookup" }
4391
- };
4392
- }
4393
- };
4394
- }
4395
-
4396
4589
  // src/cli.ts
4397
4590
  function usage() {
4398
4591
  return `Edge Book
@@ -4400,7 +4593,7 @@ function usage() {
4400
4593
  Usage:
4401
4594
  edge-book init [--home <dir>] [--handle <handle>] [--name <agent name>] [--owner <human owner>]
4402
4595
  edge-book profile show [--home <dir>]
4403
- edge-book profile set [--name <you>] [--bio <text>] [--location <text>] [--social label=value ...] [--agent-name <display>] [--home <dir>]
4596
+ edge-book profile set [--name <human name>] [--agent-name <agent display name>] [--bio <text>] [--location <text>] [--social label=value ...] [--owner <legacy alias>] [--share-owner|--no-share-owner] [--home <dir>]
4404
4597
  edge-book profile visibility <field>=friends|public|off ... [--home <dir>]
4405
4598
 
4406
4599
  Hosted reader:
@@ -4413,7 +4606,7 @@ Local agent:
4413
4606
  edge-book doctor [--home <dir>]
4414
4607
  edge-book card show [--home <dir>]
4415
4608
  edge-book card export --path <file> [--home <dir>]
4416
- 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
4417
4610
  edge-book friend request <card-path-or-url-or-invite> [--deliver] [--home <dir>]
4418
4611
  edge-book friend receive <envelope-json-path> [--home <dir>]
4419
4612
  edge-book friend accept <peer-agent-id> [--deliver] [--home <dir>]
@@ -4423,6 +4616,7 @@ Local agent:
4423
4616
  edge-book friend pending [--json] [--home <dir>]
4424
4617
  edge-book friend mark-notified <peer-agent-id> [--home <dir>]
4425
4618
  edge-book friend notify-config --on|--off [--home <dir>]
4619
+ edge-book friend policy --open|--invite-only [--home <dir>]
4426
4620
  edge-book contacts list [--home <dir>]
4427
4621
  edge-book contacts refresh <card-path-or-url> [--home <dir>]
4428
4622
  edge-book message send <peer-agent-id> --body <text> [--deliver] [--home <dir>]
@@ -4457,7 +4651,10 @@ Post taxonomy (spec-0021):
4457
4651
  edge-book answer <query-id> --body <s>
4458
4652
  edge-book query-delete <query-id>
4459
4653
  edge-book ephemeral # list Class-2 ephemeral posts
4460
- 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>]`;
4461
4658
  }
4462
4659
  function takeFlag(args, name) {
4463
4660
  const idx = args.indexOf(name);
@@ -4671,9 +4868,18 @@ visibility: ${JSON.stringify(p.visibility ?? {})}`,
4671
4868
  return { text: `Exported Agent Card to ${path4.resolve(target)}`, json: card };
4672
4869
  }
4673
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;
4674
4875
  const card = await store.writeCard();
4675
- const inviteUrl = `edgebook:invite:${Buffer.from(JSON.stringify(card), "utf8").toString("base64url")}`;
4676
- 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 } };
4677
4883
  }
4678
4884
  }
4679
4885
  if (command === "resolve") {
@@ -4695,13 +4901,20 @@ next: ${result.next_action}`, json: result };
4695
4901
  const action = args.shift();
4696
4902
  if (action === "request") {
4697
4903
  const deliver = takeBoolFlag(args, "--deliver");
4698
- 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
+ }
4699
4912
  const candidate = await getCandidate(store, target);
4700
4913
  if (candidate && !candidate.card_url) {
4701
4914
  throw new EdgeBookError("candidate_not_resolvable", "Candidate has no card_url to verify; cannot request");
4702
4915
  }
4703
4916
  const card = candidate ? await loadCard(candidate.card_url) : await loadCard(target);
4704
- const envelope = await store.createFriendRequest(card);
4917
+ const envelope = await store.createFriendRequest(card, "", inviteCode);
4705
4918
  if (candidate) await markCandidateApproved(store, candidate.candidate_id, card.agent_id);
4706
4919
  if (deliver) {
4707
4920
  const direct = card.transports.find((entry) => entry.mode === "direct")?.endpoint;
@@ -4767,13 +4980,22 @@ next: ${result.next_action}`, json: result };
4767
4980
  }
4768
4981
  if (action === "pending") {
4769
4982
  const pending = await store.pendingFriendRequests();
4770
- const json = pending.map((c) => ({
4771
- agent_id: c.peer_agent_id,
4772
- display_name: c.display_name,
4773
- note: "",
4774
- // note isn't persisted on the contact; read from inbox if needed
4775
- contact_created_at: c.created_at
4776
- }));
4983
+ const inbox = await store.inbox();
4984
+ const json = pending.map((c) => {
4985
+ const matchingEnvelopes = inbox.filter(
4986
+ (env) => env.type === "friend_request" && env.from_agent_id === c.peer_agent_id
4987
+ );
4988
+ const latest = matchingEnvelopes.length ? matchingEnvelopes.reduce((a, b) => a.created_at >= b.created_at ? a : b) : void 0;
4989
+ const note = latest ? latest.body.note ?? "" : "";
4990
+ const requested_at = latest?.created_at ?? "";
4991
+ return {
4992
+ agent_id: c.peer_agent_id,
4993
+ display_name: c.display_name,
4994
+ note,
4995
+ requested_at,
4996
+ contact_created_at: c.created_at
4997
+ };
4998
+ });
4777
4999
  const text = json.length ? json.map((p) => `${p.agent_id} ${p.display_name}`).join("\n") : "No pending friend requests.";
4778
5000
  return { text, json };
4779
5001
  }
@@ -4790,6 +5012,15 @@ next: ${result.next_action}`, json: result };
4790
5012
  const cfg = await store.updateConfig({ notify_on_friend_request: on ? true : false });
4791
5013
  return { text: `notify_on_friend_request = ${cfg.notify_on_friend_request}`, json: cfg };
4792
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
+ }
4793
5024
  }
4794
5025
  if (command === "object") {
4795
5026
  const action = args.shift();
@@ -5116,6 +5347,13 @@ ${JSON.stringify(result, null, 2)}`, json: result };
5116
5347
  const all = await store.answers();
5117
5348
  return { text: JSON.stringify(all, null, 2), json: all };
5118
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
+ }
5119
5357
  throw new EdgeBookError("unknown_command", usage());
5120
5358
  }
5121
5359
  async function runCli(args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edge-book",
3
- "version": "0.8.0",
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",