edge-book 0.3.0 → 0.5.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 +259 -6
  2. package/package.json +1 -1
package/dist/edge-book.js CHANGED
@@ -17,6 +17,12 @@ import crypto from "crypto";
17
17
  import fs from "fs/promises";
18
18
  import os from "os";
19
19
  import path from "path";
20
+ var EPHEMERAL_TTL_POLICY = {
21
+ query: { hard: true },
22
+ delegation_request: { hard: true },
23
+ share: { hard: false },
24
+ coordinate: { hard: false }
25
+ };
20
26
  var EdgeBookError = class extends Error {
21
27
  code;
22
28
  constructor(code, message) {
@@ -46,7 +52,10 @@ var ATTESTATIONS_FILE = "attestations.json";
46
52
  var ENDORSEMENTS_FILE = "endorsements.json";
47
53
  var SIGNALS_FILE = "signals.json";
48
54
  var CAPABILITIES_FILE = "capabilities.json";
55
+ var EPHEMERAL_FILE = "ephemeral-posts.json";
56
+ var ANSWERS_FILE = "answers.json";
49
57
  var DEFAULT_SIGNAL_TTL_MS = 6 * 60 * 60 * 1e3;
58
+ var DEFAULT_EPHEMERAL_TTL_MS = 24 * 60 * 60 * 1e3;
50
59
  function resolveHome(home) {
51
60
  if (home?.trim()) return path.resolve(home.trim());
52
61
  if (process.env.EDGE_BOOK_HOME?.trim()) return path.resolve(process.env.EDGE_BOOK_HOME.trim());
@@ -145,6 +154,13 @@ async function readJsonl(file) {
145
154
  function relationshipId(a, b) {
146
155
  return `rel_${crypto.createHash("sha256").update([a, b].sort().join("|")).digest("base64url").slice(0, 24)}`;
147
156
  }
157
+ function computeLifecycle(expiresAt, hard, current) {
158
+ if (current === "expired" || current === "cancelled" || current === "tombstoned") {
159
+ return current;
160
+ }
161
+ if (Date.parse(expiresAt) <= Date.now()) return hard ? "expired" : "stale";
162
+ return "active";
163
+ }
148
164
  var EdgeBookStore = class {
149
165
  home;
150
166
  constructor(options = {}) {
@@ -218,6 +234,7 @@ var EdgeBookStore = class {
218
234
  const transports = [{ mode: "local", endpoint: this.home }];
219
235
  if (config.direct_url) transports.push({ mode: "direct", endpoint: config.direct_url });
220
236
  if (config.relay_url) transports.push({ mode: "relay", endpoint: config.relay_url });
237
+ const caps = Object.values(await this.capabilities()).map((c) => ({ name: c.name, version: c.version, summary: c.summary, status: c.status }));
221
238
  const unsigned = {
222
239
  schema: "openclaw-agent-card/0.1",
223
240
  agent_id: identity.agent_id,
@@ -229,6 +246,7 @@ var EdgeBookStore = class {
229
246
  card_version: 1,
230
247
  public_keys: [{ id: `${identity.agent_id}#main`, type: "ed25519", public_key_pem: identity.public_key_pem }],
231
248
  capabilities: ["friend_request", "friend_gated_message", "feed_read_friends"],
249
+ ...caps.length ? { advertised_capabilities: caps } : {},
232
250
  transports,
233
251
  refresh_after: new Date(Date.now() + 24 * 60 * 60 * 1e3).toISOString(),
234
252
  expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toISOString()
@@ -310,6 +328,7 @@ var EdgeBookStore = class {
310
328
  // Carry the peer's shared human name (undefined if they didn't opt in, or
311
329
  // dropped on refresh if they turned sharing off).
312
330
  owner_label: card.owner_label,
331
+ advertised_capabilities: card.advertised_capabilities,
313
332
  card_url: card.card_url,
314
333
  known_endpoints: card.transports,
315
334
  public_keys: card.public_keys,
@@ -470,6 +489,7 @@ var EdgeBookStore = class {
470
489
  }
471
490
  const grant = await this.findUsableGrant(peerAgentId, scope);
472
491
  if (!grant) throw new EdgeBookError("missing_grant", `No active grant for ${scope}`);
492
+ await this.assertGrantSignature(grant);
473
493
  const envelope = await this.signEnvelope({
474
494
  type: "privileged_message",
475
495
  to_agent_id: peerAgentId,
@@ -497,6 +517,7 @@ var EdgeBookStore = class {
497
517
  if (!grant || grant.status !== "active" || grant.subject_agent_id !== envelope.from_agent_id || !grant.scopes.includes("message.friend")) {
498
518
  throw new EdgeBookError("missing_grant", "Message does not carry an active grant issued to sender");
499
519
  }
520
+ await this.assertGrantSignature(grant);
500
521
  await appendJsonl(this.file(INBOX_FILE), envelope);
501
522
  await this.audit("message.receive", envelope.from_agent_id, { message_id: envelope.message_id });
502
523
  }
@@ -614,6 +635,43 @@ var EdgeBookStore = class {
614
635
  const { signature, ...rest } = cap;
615
636
  return verifyPayload(rest, signature, pub);
616
637
  }
638
+ // Verify an EphemeralPost signature. lifecycle is NOT part of the signed payload
639
+ // (it is mutable local metadata), so strip both signature and lifecycle before verify.
640
+ async verifyEphemeral(post) {
641
+ const identity = await this.identity();
642
+ let pub = identity.agent_id === post.from_agent ? identity.public_key_pem : void 0;
643
+ if (!pub) {
644
+ const c = (await this.contacts())[post.from_agent];
645
+ pub = c?.public_keys?.[0]?.public_key_pem;
646
+ }
647
+ if (!pub) return false;
648
+ const { signature, lifecycle: _lc, ...signedPayload } = post;
649
+ return verifyPayload(signedPayload, signature, pub);
650
+ }
651
+ // Verify an Answer signature. lifecycle is NOT part of the signed payload.
652
+ async verifyAnswer(ans) {
653
+ const identity = await this.identity();
654
+ let pub = identity.agent_id === ans.answerer_agent_id ? identity.public_key_pem : void 0;
655
+ if (!pub) {
656
+ const c = (await this.contacts())[ans.answerer_agent_id];
657
+ pub = c?.public_keys?.[0]?.public_key_pem;
658
+ }
659
+ if (!pub) return false;
660
+ const { signature, lifecycle: _lc, ...signedPayload } = ans;
661
+ return verifyPayload(signedPayload, signature, pub);
662
+ }
663
+ // Verify a Signal signature. lifecycle is NOT part of the signed payload.
664
+ async verifySignal(sig) {
665
+ const identity = await this.identity();
666
+ let pub = identity.agent_id === sig.from_agent ? identity.public_key_pem : void 0;
667
+ if (!pub) {
668
+ const c = (await this.contacts())[sig.from_agent];
669
+ pub = c?.public_keys?.[0]?.public_key_pem;
670
+ }
671
+ if (!pub) return false;
672
+ const { signature, lifecycle: _lc, ...signedPayload } = sig;
673
+ return verifyPayload(signedPayload, signature, pub);
674
+ }
617
675
  // Class 3: Endorse — actor-owned reified edge, strongRef parent, evidence link (R5, R8)
618
676
  async endorsements() {
619
677
  return readJson(this.file(ENDORSEMENTS_FILE), {});
@@ -650,8 +708,7 @@ var EdgeBookStore = class {
650
708
  }
651
709
  // Class 2: Signal — ephemeral, lifecycle + TTL (R4)
652
710
  signalLifecycle(sig) {
653
- if (sig.lifecycle === "expired") return "expired";
654
- return Date.parse(sig.expires_at) <= Date.now() ? "stale" : "active";
711
+ return computeLifecycle(sig.expires_at, false, sig.lifecycle);
655
712
  }
656
713
  async signals() {
657
714
  const raw = await readJson(this.file(SIGNALS_FILE), {});
@@ -669,11 +726,10 @@ var EdgeBookStore = class {
669
726
  schema: "edge-book/signal/0.1",
670
727
  from_agent: identity.agent_id,
671
728
  body: input.body,
672
- lifecycle: "active",
673
729
  created_at: created,
674
730
  expires_at
675
731
  };
676
- const signal = { ...unsigned, signature: signPayload(unsigned, identity.private_key_pem) };
732
+ const signal = { ...unsigned, lifecycle: "active", signature: signPayload(unsigned, identity.private_key_pem) };
677
733
  const all = await this.signals();
678
734
  all[signal_id] = signal;
679
735
  await this.saveSignals(all);
@@ -691,6 +747,111 @@ var EdgeBookStore = class {
691
747
  }
692
748
  if (changed) await this.saveSignals(all);
693
749
  }
750
+ // Generic Class-2 ephemeral store (query/share/coordinate/delegation_request, R2/R4)
751
+ async saveEphemeral(posts) {
752
+ await writeJson(this.file(EPHEMERAL_FILE), posts);
753
+ }
754
+ async ephemeralPosts() {
755
+ const raw = await readJson(this.file(EPHEMERAL_FILE), {});
756
+ for (const id of Object.keys(raw)) {
757
+ raw[id].lifecycle = computeLifecycle(raw[id].expires_at, EPHEMERAL_TTL_POLICY[raw[id].post_type].hard, raw[id].lifecycle);
758
+ }
759
+ return raw;
760
+ }
761
+ async createEphemeral(type, input) {
762
+ if (!EPHEMERAL_TTL_POLICY[type]) throw new EdgeBookError("unknown_post_type", `Not an ephemeral Class-2 type: ${type}`);
763
+ const identity = await this.identity();
764
+ const post_id = randomId("eph");
765
+ const created = now();
766
+ const expires_at = new Date(Date.now() + (input.ttlMs ?? DEFAULT_EPHEMERAL_TTL_MS)).toISOString();
767
+ const unsigned = {
768
+ post_id,
769
+ post_type: type,
770
+ schema: "edge-book/ephemeral/0.1",
771
+ from_agent: identity.agent_id,
772
+ body: input.body,
773
+ ...input.subject_agent_id ? { subject_agent_id: input.subject_agent_id } : {},
774
+ ...input.ref ? { ref: input.ref } : {},
775
+ created_at: created,
776
+ expires_at
777
+ };
778
+ const post = { ...unsigned, lifecycle: "active", signature: signPayload(unsigned, identity.private_key_pem) };
779
+ const all = await this.ephemeralPosts();
780
+ all[post_id] = post;
781
+ await this.saveEphemeral(all);
782
+ await this.audit(type + ".create", identity.agent_id, { post_id, ...input.subject_agent_id ? { subject_agent_id: input.subject_agent_id } : {} });
783
+ return post;
784
+ }
785
+ async expireEphemeral() {
786
+ const all = await readJson(this.file(EPHEMERAL_FILE), {});
787
+ let changed = false;
788
+ for (const id of Object.keys(all)) {
789
+ const next = computeLifecycle(all[id].expires_at, EPHEMERAL_TTL_POLICY[all[id].post_type].hard, all[id].lifecycle);
790
+ if (next !== all[id].lifecycle) {
791
+ all[id].lifecycle = next;
792
+ changed = true;
793
+ }
794
+ }
795
+ if (changed) await this.saveEphemeral(all);
796
+ }
797
+ async cancelEphemeral(postId) {
798
+ const all = await readJson(this.file(EPHEMERAL_FILE), {});
799
+ const post = all[postId];
800
+ if (!post) throw new EdgeBookError("not_found", `No ephemeral post ${postId}`);
801
+ post.lifecycle = "cancelled";
802
+ await this.saveEphemeral(all);
803
+ await this.audit("ephemeral.cancel", post.from_agent, { post_id: postId });
804
+ return post;
805
+ }
806
+ // Class 3: Answer — actor-owned, strongRef to a Query (R5)
807
+ async saveAnswers(answers) {
808
+ await writeJson(this.file(ANSWERS_FILE), answers);
809
+ }
810
+ async answers() {
811
+ return readJson(this.file(ANSWERS_FILE), {});
812
+ }
813
+ async createAnswer(input) {
814
+ if (!input.parent?.uri || !input.parent?.hash) {
815
+ throw new EdgeBookError("missing_parent", "Answer requires a strongRef parent (uri + hash) \u2014 R5");
816
+ }
817
+ const identity = await this.identity();
818
+ const answer_id = randomId("ans");
819
+ const unsigned = {
820
+ answer_id,
821
+ post_type: "answer",
822
+ schema: "edge-book/answer/0.1",
823
+ answerer_agent_id: identity.agent_id,
824
+ // actor-owned (R5)
825
+ parent: input.parent,
826
+ body: input.body,
827
+ created_at: now()
828
+ };
829
+ const answer = { ...unsigned, lifecycle: "active", signature: signPayload(unsigned, identity.private_key_pem) };
830
+ const all = await this.answers();
831
+ all[answer_id] = answer;
832
+ await this.saveAnswers(all);
833
+ await this.audit("answer.create", identity.agent_id, { answer_id, parent: input.parent.uri });
834
+ return answer;
835
+ }
836
+ // R7: deleting a Query tombstones (archives) it AND its Answers — never hard-drops.
837
+ async deleteQuery(queryId) {
838
+ const eph = await readJson(this.file(EPHEMERAL_FILE), {});
839
+ const q = eph[queryId];
840
+ if (!q || q.post_type !== "query") throw new EdgeBookError("not_found", `No query ${queryId}`);
841
+ q.lifecycle = "tombstoned";
842
+ await this.saveEphemeral(eph);
843
+ const parentUri = "edgebook:query:" + queryId;
844
+ const ans = await this.answers();
845
+ let changed = false;
846
+ for (const id of Object.keys(ans)) {
847
+ if (ans[id].parent.uri === parentUri && ans[id].lifecycle !== "tombstoned") {
848
+ ans[id].lifecycle = "tombstoned";
849
+ changed = true;
850
+ }
851
+ }
852
+ if (changed) await this.saveAnswers(ans);
853
+ await this.audit("query.delete", q.from_agent, { query_id: queryId });
854
+ }
694
855
  // Class 1: Capability Advertisement — versioned, deprecate-not-delete (R3)
695
856
  async capabilities() {
696
857
  return readJson(this.file(CAPABILITIES_FILE), {});
@@ -745,8 +906,21 @@ var EdgeBookStore = class {
745
906
  }
746
907
  await this.saveCapabilities(caps);
747
908
  const sigs = await readJson(this.file(SIGNALS_FILE), {});
748
- for (const id of Object.keys(sigs)) sigs[id].lifecycle = "expired";
909
+ for (const id of Object.keys(sigs)) {
910
+ if (sigs[id].lifecycle !== "expired") sigs[id].lifecycle = "expired";
911
+ }
749
912
  await this.saveSignals(sigs);
913
+ const eph = await readJson(this.file(EPHEMERAL_FILE), {});
914
+ for (const id of Object.keys(eph)) {
915
+ const lc = eph[id].lifecycle;
916
+ if (lc === "expired" || lc === "cancelled" || lc === "tombstoned") continue;
917
+ const t = eph[id].post_type;
918
+ eph[id].lifecycle = t === "query" || t === "delegation_request" ? "cancelled" : "expired";
919
+ }
920
+ await this.saveEphemeral(eph);
921
+ const ans = await readJson(this.file(ANSWERS_FILE), {});
922
+ for (const id of Object.keys(ans)) if (ans[id].lifecycle !== "tombstoned") ans[id].lifecycle = "tombstoned";
923
+ await this.saveAnswers(ans);
750
924
  await this.audit("agent.deregister", (await this.identity()).agent_id, {});
751
925
  }
752
926
  // Issue an `object.read` grant binding ONE object to ONE subject (revocable).
@@ -917,6 +1091,34 @@ var EdgeBookStore = class {
917
1091
  (grant) => grant.issuer_agent_id === peerAgentId && grant.subject_agent_id === identity.agent_id && grant.status === "active" && grant.scopes.includes(scope) && (!grant.expires_at || Date.parse(grant.expires_at) > Date.now())
918
1092
  );
919
1093
  }
1094
+ // ea-openclaw-030 access check #6: a grant authorizes access only if its
1095
+ // issuer signature verifies against the issuer's accepted public key. Grants
1096
+ // are signed on issue (signPayload) but must be re-verified on use so that a
1097
+ // grant tampered after signing, or presented independently of its issuing
1098
+ // envelope, fails closed. Resolves the issuer key from local identity when
1099
+ // self-issued, else from the issuer's contact record.
1100
+ async verifyGrantSignature(grant) {
1101
+ if (!grant.signature) return false;
1102
+ const identity = await this.identity();
1103
+ let publicKey;
1104
+ if (grant.issuer_agent_id === identity.agent_id) {
1105
+ publicKey = identity.public_key_pem;
1106
+ } else {
1107
+ const contacts = await this.contacts();
1108
+ publicKey = contacts[grant.issuer_agent_id]?.public_keys?.[0]?.public_key_pem;
1109
+ }
1110
+ if (!publicKey) return false;
1111
+ return verifyPayload(withoutSignature(grant), grant.signature, publicKey);
1112
+ }
1113
+ // Throwing guard used by every friend-gated access path so the signature
1114
+ // check lives in exactly one place (ea-openclaw-031: build the grant-check
1115
+ // primitive once, have all sites consume it).
1116
+ async assertGrantSignature(grant) {
1117
+ if (!await this.verifyGrantSignature(grant)) {
1118
+ await this.audit("grant.denied", grant.issuer_agent_id, { grant_id: grant.grant_id, reason: "invalid_grant_signature" });
1119
+ throw new EdgeBookError("invalid_grant_signature", "Grant signature does not verify against the issuer key");
1120
+ }
1121
+ }
920
1122
  async signEnvelope(input) {
921
1123
  const identity = await this.identity();
922
1124
  const unsigned = {
@@ -1227,6 +1429,7 @@ var EdgeBookStore = class {
1227
1429
  (candidate) => candidate.issuer_agent_id === identity.agent_id && candidate.subject_agent_id === peerAgentId && candidate.status === "active" && candidate.scopes.includes("feed.read.friends") && (!candidate.expires_at || Date.parse(candidate.expires_at) > Date.now())
1228
1430
  );
1229
1431
  if (!grant) throw new EdgeBookError("missing_grant", "No active feed.read.friends grant for peer");
1432
+ await this.assertGrantSignature(grant);
1230
1433
  const posts = Object.values(await this.posts());
1231
1434
  return posts.filter((post) => post.visibility === "friends" && ["published", "edited"].includes(post.status)).filter((post) => !post.expires_at || Date.parse(post.expires_at) > Date.now()).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
1232
1435
  }
@@ -1567,6 +1770,14 @@ async function handleOwnerApi(req, res, url, adapters) {
1567
1770
  sendJson(res, 200, { capabilities: await store.capabilities() });
1568
1771
  return true;
1569
1772
  }
1773
+ if (req.method === "GET" && url.pathname === "/api/ephemeral") {
1774
+ sendJson(res, 200, { ephemeral: await store.ephemeralPosts() });
1775
+ return true;
1776
+ }
1777
+ if (req.method === "GET" && url.pathname === "/api/answers") {
1778
+ sendJson(res, 200, { answers: await store.answers() });
1779
+ return true;
1780
+ }
1570
1781
  if (req.method === "GET" && url.pathname === "/api/shared-objects") {
1571
1782
  const objects = await store.sharedObjectsFor();
1572
1783
  sendJson(res, 200, { objects: objects.map((object) => ({ ...object, grant_scope: "object.read" })) });
@@ -3434,7 +3645,15 @@ Post taxonomy (spec-0021):
3434
3645
  edge-book signal --body <s> [--ttl-ms <ms>]
3435
3646
  edge-book capability advertise --name <n> --version <v> --summary <s>
3436
3647
  edge-book capability deprecate <capability-id>
3437
- edge-book capability list`;
3648
+ edge-book capability list
3649
+ edge-book query --body <s> [--ttl-ms <ms>]
3650
+ edge-book share --body <s> [--ref <r>] [--ttl-ms <ms>]
3651
+ edge-book coordinate --body <s> [--with <agent>] [--ttl-ms <ms>]
3652
+ edge-book delegate --to <agent> --body <s> [--ttl-ms <ms>]
3653
+ edge-book answer <query-id> --body <s>
3654
+ edge-book query-delete <query-id>
3655
+ edge-book ephemeral # list Class-2 ephemeral posts
3656
+ edge-book answers # list answers`;
3438
3657
  }
3439
3658
  function takeFlag(args, name) {
3440
3659
  const idx = args.indexOf(name);
@@ -3825,6 +4044,40 @@ ${JSON.stringify(result, null, 2)}`, json: result };
3825
4044
  }
3826
4045
  throw new EdgeBookError("unknown_action", `Unknown capability action: ${action}`);
3827
4046
  }
4047
+ if (command === "query" || command === "share" || command === "coordinate" || command === "delegate") {
4048
+ const type = command === "delegate" ? "delegation_request" : command;
4049
+ const body = requireArg(takeFlag(args, "--body"), "--body");
4050
+ const to = takeFlag(args, "--to") || takeFlag(args, "--with");
4051
+ const ref = takeFlag(args, "--ref");
4052
+ const ttl = takeFlag(args, "--ttl-ms");
4053
+ const post = await store.createEphemeral(type, { body, subject_agent_id: to, ref, ttlMs: ttl ? Number(ttl) : void 0 });
4054
+ return { text: `${post.post_type} ${post.post_id}`, json: post };
4055
+ }
4056
+ if (command === "answer") {
4057
+ const queryId = requireArg(args.shift(), "<query-id>");
4058
+ const ephemeral = await store.ephemeralPosts();
4059
+ const query = ephemeral[queryId];
4060
+ if (!query) throw new EdgeBookError("not_found", `No local query ${queryId} to answer`);
4061
+ const { signature: _sig, lifecycle: _lc, ...queryUnsigned } = query;
4062
+ const ans = await store.createAnswer({
4063
+ parent: { uri: "edgebook:query:" + queryId, hash: contentHash(queryUnsigned) },
4064
+ body: requireArg(takeFlag(args, "--body"), "--body")
4065
+ });
4066
+ return { text: `answer ${ans.answer_id}`, json: ans };
4067
+ }
4068
+ if (command === "query-delete") {
4069
+ const queryId = requireArg(args.shift(), "<query-id>");
4070
+ await store.deleteQuery(queryId);
4071
+ return { text: `Tombstoned query ${queryId} and its answers`, json: { query_id: queryId } };
4072
+ }
4073
+ if (command === "ephemeral") {
4074
+ const all = await store.ephemeralPosts();
4075
+ return { text: JSON.stringify(all, null, 2), json: all };
4076
+ }
4077
+ if (command === "answers") {
4078
+ const all = await store.answers();
4079
+ return { text: JSON.stringify(all, null, 2), json: all };
4080
+ }
3828
4081
  throw new EdgeBookError("unknown_command", usage());
3829
4082
  }
3830
4083
  async function runCli(args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edge-book",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",