@adapt-toolkit/a2adapt 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -321,9 +321,15 @@ function cmdWatch(which) {
321
321
  } catch {
322
322
  continue;
323
323
  }
324
- out(
325
- `[${name}] new message from ${msg.from ?? "?"}` + (msg.msg_id !== void 0 ? ` (#${msg.msg_id})` : "") + (msg.date ? ` (${msg.date})` : "")
326
- );
324
+ if (msg.event === "local_contact_request") {
325
+ out(`[${name}] pending local introduction from ${msg.from ?? "?"} \u2014 respond_to_introduction to approve/reject`);
326
+ } else if (msg.event === "pending_message") {
327
+ out(`[${name}] ${msg.from ?? "?"} queued a message awaiting introduction approval (${msg.queued ?? "?"} queued)`);
328
+ } else {
329
+ out(
330
+ `[${name}] new message from ${msg.from ?? "?"}` + (msg.msg_id !== void 0 ? ` (#${msg.msg_id})` : "") + (msg.date ? ` (${msg.date})` : "")
331
+ );
332
+ }
327
333
  }
328
334
  }
329
335
  };
@@ -86,8 +86,14 @@ function findPinnedIdentity(start) {
86
86
  continue;
87
87
  }
88
88
  try {
89
- const name = String(JSON.parse(raw).identity ?? "").trim();
90
- return name || null;
89
+ const parsed = JSON.parse(raw);
90
+ const name = String(parsed.identity ?? "").trim();
91
+ if (!name) return null;
92
+ const pin = { identity: name };
93
+ if (typeof parsed.force === "boolean") pin.force = parsed.force;
94
+ if (typeof parsed.expose_local === "boolean") pin.expose_local = parsed.expose_local;
95
+ if (typeof parsed.local_auto_accept === "boolean") pin.local_auto_accept = parsed.local_auto_accept;
96
+ return pin;
91
97
  } catch {
92
98
  return null;
93
99
  }
@@ -117,9 +123,20 @@ function anyIdentityBound() {
117
123
  return err.code === "EPERM";
118
124
  }
119
125
  }
120
- function renderIdentityDirective(name, exists) {
121
- const bind = exists ? `call \`choose_identity({ name: "${name}" })\` to bind it to this session` : `it does not exist yet \u2014 call \`create_identity({ name: "${name}" })\` to create and bind it`;
122
- return `a2adapt \u2014 this workspace is pinned to identity "${name}" (via ${IDENTITY_FILE}). Before other a2adapt work, ${bind}. Then arm a Monitor on the wake source \`a2adapt-mcp watch ${name}\` so new mail wakes you. Do this once, up front. The pin is the workspace default: if the user explicitly asks to use a different identity, bind that instead \u2014 the user's choice always wins. If choose_identity reports the identity is held by another session, do NOT retry with force \u2014 tell the user it is bound elsewhere and ask whether to forcibly rebind it to this session; only pass force=true after they confirm.`;
126
+ function renderIdentityDirective(pin, exists) {
127
+ const name = pin.identity;
128
+ let bind;
129
+ if (exists) {
130
+ bind = pin.force ? `call \`choose_identity({ name: "${name}", force: true })\` to bind it to this session (the workspace pin sets force, so evicting another holder is pre-authorized \u2014 no need to ask)` : `call \`choose_identity({ name: "${name}" })\` to bind it to this session`;
131
+ } else {
132
+ const extras = [];
133
+ if (pin.expose_local !== void 0) extras.push(`expose_local: ${pin.expose_local}`);
134
+ if (pin.local_auto_accept !== void 0) extras.push(`local_auto_accept: ${pin.local_auto_accept}`);
135
+ const args = [`name: "${name}"`, ...extras].join(", ");
136
+ bind = `it does not exist yet \u2014 call \`create_identity({ ${args} })\` to create and bind it`;
137
+ }
138
+ const forceTail = pin.force ? "" : ` If choose_identity reports the identity is held by another session, do NOT retry with force \u2014 tell the user it is bound elsewhere and ask whether to forcibly rebind it to this session; only pass force=true after they confirm.`;
139
+ return `a2adapt \u2014 this workspace is pinned to identity "${name}" (via ${IDENTITY_FILE}). Before other a2adapt work, ${bind}. Then arm a Monitor on the wake source \`a2adapt-mcp watch ${name}\` so new mail wakes you. Do this once, up front. The pin is the workspace default: if the user explicitly asks to use a different identity, bind that instead \u2014 the user's choice always wins.` + forceTail;
123
140
  }
124
141
  function sessionStart() {
125
142
  const raw = readStdin();
@@ -137,7 +154,7 @@ function sessionStart() {
137
154
  const pinned = findPinnedIdentity(cwd);
138
155
  const unread = collectUnread();
139
156
  const blocks = [];
140
- if (pinned) blocks.push(renderIdentityDirective(pinned, identityExists(pinned)));
157
+ if (pinned) blocks.push(renderIdentityDirective(pinned, identityExists(pinned.identity)));
141
158
  if (unread.length > 0) blocks.push(renderContext(unread));
142
159
  if (blocks.length === 0) return noop();
143
160
  emit({
@@ -164,7 +181,7 @@ function userPromptSubmit() {
164
181
  continue: true,
165
182
  hookSpecificOutput: {
166
183
  hookEventName: "UserPromptSubmit",
167
- additionalContext: renderIdentityDirective(pinned, identityExists(pinned))
184
+ additionalContext: renderIdentityDirective(pinned, identityExists(pinned.identity))
168
185
  }
169
186
  });
170
187
  }
package/dist/index.js CHANGED
@@ -22489,7 +22489,7 @@ function loadConfig() {
22489
22489
  }
22490
22490
 
22491
22491
  // src/index.ts
22492
- var VERSION = true ? "0.8.0" : "0.0.0-dev";
22492
+ var VERSION = true ? "0.8.1" : "0.0.0-dev";
22493
22493
  var CONFIG = loadConfig();
22494
22494
  var STATE_DIR = CONFIG.stateDir;
22495
22495
  var BROKER_URL = CONFIG.brokerUrl;
@@ -22499,6 +22499,7 @@ var GC_INTERVAL_MS = CONFIG.gcIntervalMs;
22499
22499
  var log = (...parts) => process.stderr.write(`a2adapt: ${parts.join(" ")}
22500
22500
  `);
22501
22501
  var NAME_RE = /^[A-Za-z0-9 _.-]{1,64}$/;
22502
+ var BOOK_DIR_NAME = "contact-book";
22502
22503
  function validateName(name) {
22503
22504
  if (!NAME_RE.test(name)) {
22504
22505
  return "name must be 1-64 chars of letters, digits, space, _ . or -";
@@ -22506,6 +22507,9 @@ function validateName(name) {
22506
22507
  if (name === "." || name === ".." || name.includes("/") || name.includes("\\")) {
22507
22508
  return "invalid name";
22508
22509
  }
22510
+ if (name === BOOK_DIR_NAME) {
22511
+ return `"${BOOK_DIR_NAME}" is reserved for the local contact book`;
22512
+ }
22509
22513
  return null;
22510
22514
  }
22511
22515
  function locateUnit() {
@@ -22528,6 +22532,8 @@ function locateUnit() {
22528
22532
  var UNIT;
22529
22533
  var wrapper;
22530
22534
  var identities = /* @__PURE__ */ new Map();
22535
+ var registrar = null;
22536
+ var registrarAdBlob = null;
22531
22537
  var sessionBinding = /* @__PURE__ */ new Map();
22532
22538
  var bindingOwner = /* @__PURE__ */ new Map();
22533
22539
  var evictedSessions = /* @__PURE__ */ new Set();
@@ -22551,6 +22557,102 @@ function listPersistedNames() {
22551
22557
  if (!fs2.existsSync(STATE_DIR)) return [];
22552
22558
  return fs2.readdirSync(STATE_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && fs2.existsSync(seedPath(join2(STATE_DIR, d.name)))).map((d) => d.name);
22553
22559
  }
22560
+ var bookDir = () => join2(STATE_DIR, BOOK_DIR_NAME);
22561
+ var registrarSeedPath = () => join2(bookDir(), "registrar.seed");
22562
+ var bookPath = () => join2(bookDir(), "book.json");
22563
+ function readBook() {
22564
+ try {
22565
+ const parsed = JSON.parse(fs2.readFileSync(bookPath(), "utf8"));
22566
+ return parsed && typeof parsed.entries === "object" ? parsed.entries : {};
22567
+ } catch {
22568
+ return {};
22569
+ }
22570
+ }
22571
+ function writeBook(entries) {
22572
+ fs2.mkdirSync(bookDir(), { recursive: true });
22573
+ const tmp = `${bookPath()}.tmp`;
22574
+ fs2.writeFileSync(tmp, JSON.stringify({ v: 1, entries }, null, 2), { mode: 384 });
22575
+ fs2.renameSync(tmp, bookPath());
22576
+ }
22577
+ function exportAdBlob(id) {
22578
+ return Buffer.from(readonlyTx(id, "::actor::export_address_document").GetBinary());
22579
+ }
22580
+ async function publishToBook(id) {
22581
+ if (!registrar) throw new Error("registrar is not available");
22582
+ const adBlob = exportAdBlob(id);
22583
+ const sigData = await mutatingTx(registrar, "::actor::sign_book_entry", {
22584
+ name: id.name,
22585
+ ad: registrar.pw.packet.NewBinaryFromBuffer(adBlob)
22586
+ });
22587
+ const entries = readBook();
22588
+ entries[id.name] = {
22589
+ v: 1,
22590
+ name: id.name,
22591
+ container_id: id.cid,
22592
+ address_document: adBlob.toString("base64url"),
22593
+ published_at: (/* @__PURE__ */ new Date()).toISOString(),
22594
+ registrar_sig: Buffer.from(sigData.Reduce("sig").GetBinary()).toString("base64url")
22595
+ };
22596
+ writeBook(entries);
22597
+ log(`[${id.name}] published to the local contact book`);
22598
+ }
22599
+ function unpublishFromBook(name) {
22600
+ const entries = readBook();
22601
+ if (!(name in entries)) return;
22602
+ delete entries[name];
22603
+ writeBook(entries);
22604
+ log(`[${name}] removed from the local contact book`);
22605
+ }
22606
+ async function pinRegistrar(id) {
22607
+ if (!registrarAdBlob) throw new Error("registrar is not available");
22608
+ await mutatingTx(id, "::actor::pin_registrar", {
22609
+ registrar_ad: id.pw.packet.NewBinaryFromBuffer(registrarAdBlob)
22610
+ });
22611
+ }
22612
+ async function sendViaLocalBook(id, contact, text) {
22613
+ if (!registrar) {
22614
+ throw new Error(`"${contact}" is not a contact, and the local contact book is unavailable.`);
22615
+ }
22616
+ const entries = readBook();
22617
+ const entry = entries[contact] ?? Object.values(entries).find((e) => e.container_id === contact);
22618
+ if (!entry) {
22619
+ throw new Error(
22620
+ `"${contact}" is not a contact and has no local contact-book entry. Use generate_invite/add_contact for remote peers, or list_local_contact_book to see local ones.`
22621
+ );
22622
+ }
22623
+ if (entry.container_id === id.cid) {
22624
+ throw new Error("that contact-book entry is this identity itself.");
22625
+ }
22626
+ const targetAd = Buffer.from(entry.address_document, "base64url");
22627
+ const entrySig = Buffer.from(entry.registrar_sig, "base64url");
22628
+ const joinerAd = exportAdBlob(id);
22629
+ const minted = await mutatingTx(registrar, "::actor::mint_introduction", {
22630
+ joiner_ad: registrar.pw.packet.NewBinaryFromBuffer(joinerAd),
22631
+ target_ad: registrar.pw.packet.NewBinaryFromBuffer(targetAd)
22632
+ });
22633
+ const introBlob = Buffer.from(minted.Reduce("intro").GetBinary());
22634
+ await mutatingTx(id, "::actor::connect_local", {
22635
+ name: entry.name,
22636
+ target_ad: id.pw.packet.NewBinaryFromBuffer(targetAd),
22637
+ intro: id.pw.packet.NewBinaryFromBuffer(introBlob),
22638
+ entry_sig: id.pw.packet.NewBinaryFromBuffer(entrySig),
22639
+ text
22640
+ });
22641
+ return `"${entry.name}" was not a contact yet \u2014 connected via the local contact book and sent the message with the introduction. If "${entry.name}" requires approval for local introductions, delivery completes once they approve.`;
22642
+ }
22643
+ async function ensureRegistrar() {
22644
+ fs2.mkdirSync(bookDir(), { recursive: true });
22645
+ let seed;
22646
+ try {
22647
+ seed = fs2.readFileSync(registrarSeedPath(), "utf8").trim();
22648
+ } catch {
22649
+ seed = randomBytes(24).toString("hex");
22650
+ fs2.writeFileSync(registrarSeedPath(), seed, { mode: 384 });
22651
+ }
22652
+ registrar = await createPacket(BOOK_DIR_NAME, seed, bookDir(), false);
22653
+ registrarAdBlob = exportAdBlob(registrar);
22654
+ log(`contact-book registrar ready (${registrar.cid})`);
22655
+ }
22554
22656
  function hasSavedState(dir) {
22555
22657
  try {
22556
22658
  return fs2.existsSync(dataPath(dir)) && fs2.statSync(dataPath(dir)).size > 0;
@@ -22572,11 +22674,10 @@ function saveState(id) {
22572
22674
  log(`[${id.name}] failed to save state:`, String(err));
22573
22675
  }
22574
22676
  }
22575
- function appendNotifyLog(id, from, msgId, date3) {
22677
+ function appendNotifyLog(id, event) {
22576
22678
  try {
22577
22679
  fs2.mkdirSync(id.dir, { recursive: true });
22578
- const line = JSON.stringify({ event: "message_received", from, msg_id: msgId, date: date3 }) + "\n";
22579
- fs2.appendFileSync(notifyLogPath(id.dir), line);
22680
+ fs2.appendFileSync(notifyLogPath(id.dir), JSON.stringify(event) + "\n");
22580
22681
  } catch (err) {
22581
22682
  log(`[${id.name}] failed to append notifications.log:`, String(err));
22582
22683
  }
@@ -22660,7 +22761,7 @@ function wireHandlers(id) {
22660
22761
  const sender = payload.Reduce("sender_name").Visualize();
22661
22762
  const msgId = payload.Reduce("msg_id").Visualize();
22662
22763
  const date3 = payload.Reduce("date").Visualize();
22663
- appendNotifyLog(id, sender, msgId, date3);
22764
+ appendNotifyLog(id, { event: "message_received", from: sender, msg_id: msgId, date: date3 });
22664
22765
  refreshUnread(id);
22665
22766
  process.nextTick(
22666
22767
  () => pushNotification(id.name, `[${id.name}] new message from ${sender} (#${msgId})`)
@@ -22671,6 +22772,29 @@ function wireHandlers(id) {
22671
22772
  process.nextTick(
22672
22773
  () => pushNotification(id.name, `[${id.name}] contact "${name}" (${cid}) accepted your invite.`)
22673
22774
  );
22775
+ } else if (event === "local_contact_added") {
22776
+ const name = payload.Reduce("name").Visualize();
22777
+ const cid = payload.Reduce("container_id").Visualize();
22778
+ process.nextTick(
22779
+ () => pushNotification(id.name, `[${id.name}] local contact "${name}" (${cid}) connected via the contact book.`)
22780
+ );
22781
+ } else if (event === "local_contact_request") {
22782
+ const name = payload.Reduce("name").Visualize();
22783
+ const cid = payload.Reduce("container_id").Visualize();
22784
+ appendNotifyLog(id, { event: "local_contact_request", from: name });
22785
+ process.nextTick(
22786
+ () => pushNotification(
22787
+ id.name,
22788
+ `[${id.name}] pending local introduction from "${name}" (${cid}) \u2014 approve or reject with respond_to_introduction.`
22789
+ )
22790
+ );
22791
+ } else if (event === "pending_message") {
22792
+ const name = payload.Reduce("sender_name").Visualize();
22793
+ const queued = payload.Reduce("queued").Visualize();
22794
+ appendNotifyLog(id, { event: "pending_message", from: name, queued });
22795
+ process.nextTick(
22796
+ () => pushNotification(id.name, `[${id.name}] "${name}" queued a message awaiting introduction approval (${queued} queued).`)
22797
+ );
22674
22798
  }
22675
22799
  return;
22676
22800
  }
@@ -22689,7 +22813,7 @@ function wireHandlers(id) {
22689
22813
  }
22690
22814
  };
22691
22815
  }
22692
- function createPacket(name, seed, dir) {
22816
+ function createPacket(name, seed, dir, track = true) {
22693
22817
  const config2 = new PacketWrapperConfigurator();
22694
22818
  config2.process_arguments([
22695
22819
  "--unit_hash",
@@ -22718,7 +22842,7 @@ function createPacket(name, seed, dir) {
22718
22842
  lock: Promise.resolve()
22719
22843
  };
22720
22844
  wireHandlers(id);
22721
- identities.set(name, id);
22845
+ if (track) identities.set(name, id);
22722
22846
  log(`[${name}] packet created \u2014 container id ${id.cid}`);
22723
22847
  resolveCreate(id);
22724
22848
  },
@@ -22726,13 +22850,20 @@ function createPacket(name, seed, dir) {
22726
22850
  );
22727
22851
  });
22728
22852
  }
22729
- async function provisionIdentity(name) {
22853
+ async function provisionIdentity(name, opts = { exposeLocal: true, localAutoAccept: true }) {
22730
22854
  const dir = identityDir(name);
22731
22855
  fs2.mkdirSync(dir, { recursive: true });
22732
22856
  const seed = randomBytes(24).toString("hex");
22733
22857
  fs2.writeFileSync(seedPath(dir), seed, { mode: 384 });
22734
22858
  const id = await createPacket(name, seed, dir);
22735
22859
  await mutatingTx(id, "::actor::set_my_name", { name });
22860
+ await pinRegistrar(id);
22861
+ if (!opts.localAutoAccept) {
22862
+ await mutatingTx(id, "::actor::set_local_policy", { auto_accept: false });
22863
+ }
22864
+ if (opts.exposeLocal) {
22865
+ await publishToBook(id);
22866
+ }
22736
22867
  saveState(id);
22737
22868
  return id;
22738
22869
  }
@@ -22769,6 +22900,11 @@ async function bootWrapper() {
22769
22900
  wrapper = await adapt_wrapper.start(argv);
22770
22901
  wrapper.on_packet_created_cb = (cid) => log(`wrapper: packet ready ${cid.slice(0, 12)}\u2026`);
22771
22902
  wrapper.start();
22903
+ try {
22904
+ await ensureRegistrar();
22905
+ } catch (err) {
22906
+ log("failed to start the contact-book registrar (local contact book disabled):", String(err));
22907
+ }
22772
22908
  const names = listPersistedNames();
22773
22909
  if (names.length === 0) {
22774
22910
  log("no persisted identities \u2014 start with create_identity");
@@ -22776,7 +22912,10 @@ async function bootWrapper() {
22776
22912
  log(`restoring ${names.length} identit${names.length === 1 ? "y" : "ies"}: ${names.join(", ")}`);
22777
22913
  for (const name of names) {
22778
22914
  try {
22779
- await restoreIdentity(name);
22915
+ const id = await restoreIdentity(name);
22916
+ if (registrar) {
22917
+ await pinRegistrar(id);
22918
+ }
22780
22919
  } catch (err) {
22781
22920
  log(`failed to restore "${name}":`, String(err));
22782
22921
  }
@@ -22841,6 +22980,20 @@ function renderInbox(v) {
22841
22980
  }
22842
22981
  return out;
22843
22982
  }
22983
+ function renderPending(v) {
22984
+ const out = [];
22985
+ if (v.IsNil()) return out;
22986
+ for (const key of v.GetKeys()) {
22987
+ const p = v.Reduce(key);
22988
+ if (p.IsNil()) continue;
22989
+ out.push({
22990
+ container_id: typeof key === "string" ? key : key.Visualize(),
22991
+ name: p.Reduce("name").Visualize(),
22992
+ queued: parseInt(p.Reduce("queued").Visualize(), 10) || 0
22993
+ });
22994
+ }
22995
+ return out;
22996
+ }
22844
22997
  function textResult(text, isError = false) {
22845
22998
  return { content: [{ type: "text", text }], isError };
22846
22999
  }
@@ -22876,16 +23029,21 @@ function createMcpServer(getSessionId) {
22876
23029
  };
22877
23030
  server.tool(
22878
23031
  "create_identity",
22879
- "Create a new self-sovereign identity (an ADAPT node) with the given display name and bind it to this session. The name is what peers see for you in invites. Persisted permanently; reject if the name already exists.",
22880
- { name: external_exports.string().min(1).describe('Display name for the new identity, e.g. "Alice".') },
22881
- async ({ name }) => {
23032
+ "Create a new self-sovereign identity (an ADAPT node) with the given display name and bind it to this session. The name is what peers see for you in invites. Persisted permanently; reject if the name already exists. By default the identity is published to the LOCAL contact book, so other identities on this host can message it by name without an invite; pass expose_local=false to opt out.",
23033
+ {
23034
+ name: external_exports.string().min(1).describe('Display name for the new identity, e.g. "Alice".'),
23035
+ expose_local: external_exports.boolean().default(true).describe("Publish this identity in the host-local contact book."),
23036
+ local_auto_accept: external_exports.boolean().default(true).describe("Auto-accept local contact-book introductions (false = they queue for approval).")
23037
+ },
23038
+ async ({ name, expose_local, local_auto_accept }) => {
22882
23039
  const bad = validateName(name);
22883
23040
  if (bad) return textResult(`create_identity failed: ${bad}`, true);
22884
23041
  if (identities.has(name)) return textResult(`create_identity failed: an identity named "${name}" already exists.`, true);
22885
23042
  try {
22886
- const id = await provisionIdentity(name);
23043
+ const id = await provisionIdentity(name, { exposeLocal: expose_local, localAutoAccept: local_auto_accept });
22887
23044
  bindSession(getSessionId(), name);
22888
- return textResult(`Created identity "${name}" (${id.cid}) and bound it to this session.`);
23045
+ const exposure = expose_local ? ` Published to the local contact book${local_auto_accept ? "" : " (introductions require approval)"}.` : " Not exposed in the local contact book.";
23046
+ return textResult(`Created identity "${name}" (${id.cid}) and bound it to this session.${exposure}`);
22889
23047
  } catch (err) {
22890
23048
  return textResult(`create_identity failed: ${String(err)}`, true);
22891
23049
  }
@@ -22960,6 +23118,11 @@ ${lines.join("\n")}`);
22960
23118
  log(`remove_packet(${id.cid}) failed:`, String(err));
22961
23119
  }
22962
23120
  identities.delete(name);
23121
+ try {
23122
+ unpublishFromBook(name);
23123
+ } catch (err) {
23124
+ log(`failed to unpublish "${name}" from the contact book:`, String(err));
23125
+ }
22963
23126
  const holder = bindingOwner.get(name);
22964
23127
  if (holder) {
22965
23128
  bindingOwner.delete(name);
@@ -23040,24 +23203,113 @@ ${blob}`
23040
23203
  );
23041
23204
  server.tool(
23042
23205
  "list_contacts",
23043
- "List the contacts the bound identity knows about (name + container id).",
23206
+ "List the contacts the bound identity knows about (name + container id), plus any pending local-contact-book introductions awaiting approval.",
23044
23207
  {},
23045
23208
  async () => {
23046
23209
  const { id, err } = boundOr();
23047
23210
  if (err) return err;
23048
23211
  try {
23049
23212
  const contacts = renderContacts(readonlyTx(id, "::actor::list_contacts"));
23050
- if (contacts.length === 0) return textResult("No contacts yet.");
23051
- return textResult(`Contacts (${contacts.length}):
23052
- ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`);
23213
+ const pending = renderPending(readonlyTx(id, "::actor::list_pending_introductions"));
23214
+ const lines = [];
23215
+ lines.push(
23216
+ contacts.length === 0 ? "No contacts yet." : `Contacts (${contacts.length}):
23217
+ ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`
23218
+ );
23219
+ if (pending.length > 0) {
23220
+ lines.push(
23221
+ `Pending local introductions (${pending.length}) \u2014 approve/reject with respond_to_introduction:
23222
+ ` + pending.map((p) => `\u2022 ${p.name} \u2014 ${p.container_id} (${p.queued} queued message${p.queued === 1 ? "" : "s"})`).join("\n")
23223
+ );
23224
+ }
23225
+ return textResult(lines.join("\n\n"));
23053
23226
  } catch (e) {
23054
23227
  return textResult(`list_contacts failed: ${String(e)}`, true);
23055
23228
  }
23056
23229
  }
23057
23230
  );
23231
+ server.tool(
23232
+ "list_local_contact_book",
23233
+ "List the host-local contact book: identities on THIS host that are exposed for inviteless connection. Any of them can be messaged directly with send_message.",
23234
+ {},
23235
+ async () => {
23236
+ const entries = Object.values(readBook());
23237
+ if (entries.length === 0) return textResult("The local contact book is empty.");
23238
+ const sid = getSessionId();
23239
+ const mine = sessionBinding.get(sid);
23240
+ const lines = entries.map((e) => {
23241
+ const tag = e.name === mine ? " \u2190 this session" : "";
23242
+ return `\u2022 ${e.name} \u2014 ${e.container_id} (published ${e.published_at})${tag}`;
23243
+ });
23244
+ return textResult(`Local contact book (${entries.length}):
23245
+ ${lines.join("\n")}`);
23246
+ }
23247
+ );
23248
+ server.tool(
23249
+ "set_local_book_policy",
23250
+ "Change the bound identity's local-contact-book settings: expose (publish/unpublish it in the book) and/or auto_accept (whether local introductions are accepted automatically or queue for approval).",
23251
+ {
23252
+ expose: external_exports.boolean().optional().describe("Publish (true) or remove (false) this identity in the local contact book."),
23253
+ auto_accept: external_exports.boolean().optional().describe("Auto-accept local introductions (false = queue them for approval).")
23254
+ },
23255
+ async ({ expose, auto_accept }) => {
23256
+ const { id, err } = boundOr();
23257
+ if (err) return err;
23258
+ if (expose === void 0 && auto_accept === void 0) {
23259
+ return textResult("set_local_book_policy: pass expose and/or auto_accept.", true);
23260
+ }
23261
+ const done = [];
23262
+ try {
23263
+ if (auto_accept !== void 0) {
23264
+ await mutatingTx(id, "::actor::set_local_policy", { auto_accept });
23265
+ done.push(`auto_accept=${auto_accept}`);
23266
+ }
23267
+ if (expose === true) {
23268
+ await publishToBook(id);
23269
+ done.push("published in the local contact book");
23270
+ } else if (expose === false) {
23271
+ unpublishFromBook(id.name);
23272
+ done.push("removed from the local contact book");
23273
+ }
23274
+ return textResult(`Updated "${id.name}": ${done.join("; ")}.`);
23275
+ } catch (e) {
23276
+ return textResult(`set_local_book_policy failed: ${String(e)}`, true);
23277
+ }
23278
+ }
23279
+ );
23280
+ server.tool(
23281
+ "respond_to_introduction",
23282
+ "Approve or reject a pending local-contact-book introduction (see list_contacts for the pending list). Approving registers the contact and delivers any messages it queued while waiting; rejecting drops the introduction and its queue.",
23283
+ {
23284
+ contact: external_exports.string().min(1).describe("Pending introduction to act on (name or container id)."),
23285
+ action: external_exports.enum(["approve", "reject"]).describe("approve or reject.")
23286
+ },
23287
+ async ({ contact, action }) => {
23288
+ const { id, err } = boundOr();
23289
+ if (err) return err;
23290
+ try {
23291
+ if (action === "approve") {
23292
+ const data2 = await mutatingTx(id, "::actor::approve_introduction", { contact });
23293
+ const name2 = data2.Reduce("approved").Visualize();
23294
+ const cid = data2.Reduce("container_id").Visualize();
23295
+ const flushed = data2.Reduce("flushed").Visualize();
23296
+ refreshUnread(id);
23297
+ return textResult(
23298
+ `Approved "${name2}" (${cid}) \u2014 now a contact. ${flushed} queued message(s) moved to the inbox (read them with get_messages).`
23299
+ );
23300
+ }
23301
+ const data = await mutatingTx(id, "::actor::reject_introduction", { contact });
23302
+ const name = data.Reduce("rejected").Visualize();
23303
+ const dropped = data.Reduce("dropped_messages").Visualize();
23304
+ return textResult(`Rejected the introduction from "${name}" and dropped ${dropped} queued message(s).`);
23305
+ } catch (e) {
23306
+ return textResult(`respond_to_introduction failed: ${String(e)}`, true);
23307
+ }
23308
+ }
23309
+ );
23058
23310
  server.tool(
23059
23311
  "send_message",
23060
- "Send an end-to-end-encrypted message to a known contact (by name or container id). Requires a bound identity.",
23312
+ "Send an end-to-end-encrypted message to a known contact (by name or container id). If the recipient is not a contact yet but is published in the host-local contact book, the connection is established automatically (no invite needed) and the message is delivered with the introduction. Requires a bound identity.",
23061
23313
  {
23062
23314
  contact: external_exports.string().min(1).describe("Contact name or container id to send to."),
23063
23315
  text: external_exports.string().min(1).describe("The message text.")
@@ -23069,13 +23321,21 @@ ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`)
23069
23321
  await mutatingTx(id, "::actor::send_message", { contact, text });
23070
23322
  return textResult(`Message sent to "${contact}".`);
23071
23323
  } catch (e) {
23072
- return textResult(`send_message failed: ${String(e)}`, true);
23324
+ if (!/Unknown contact/.test(String(e))) {
23325
+ return textResult(`send_message failed: ${String(e)}`, true);
23326
+ }
23327
+ try {
23328
+ const sent = await sendViaLocalBook(id, contact, text);
23329
+ return textResult(sent);
23330
+ } catch (e2) {
23331
+ return textResult(`send_message failed: ${String(e2)}`, true);
23332
+ }
23073
23333
  }
23074
23334
  }
23075
23335
  );
23076
23336
  server.tool(
23077
23337
  "remove_contact",
23078
- "Forget a contact (by name or container id) \u2014 drops it from the bound identity's contacts, so you can no longer message them and inbound messages from them are rejected. This is a contacts-layer forget, NOT a key wipe: the per-peer channel key material persists, so re-adding the same peer reuses the existing encrypted channel rather than re-handshaking. Requires a bound identity.",
23338
+ "Forget a contact (by name or container id) \u2014 drops it from the bound identity's contacts, so you can no longer message them and inbound messages from them are rejected. This is a contacts-layer forget, NOT a key wipe: the per-peer channel key material persists, so re-adding the same peer reuses the existing encrypted channel rather than re-handshaking. Note: if the removed peer is still published in the host-local contact book, a later send_message to it will reconnect through the book. Requires a bound identity.",
23079
23339
  { contact: external_exports.string().min(1).describe("Contact name or container id to remove.") },
23080
23340
  async ({ contact }) => {
23081
23341
  const { id, err } = boundOr();
@@ -17,9 +17,22 @@
17
17
  // defer_messages — flip processed/ready_to_delete messages back to unread
18
18
  // gc — two-generation GC of handled messages (host-fired, not a tool)
19
19
  //
20
+ // Local contact book (host-fired transactions; see the design notes above
21
+ // local_introduce below):
22
+ // export_address_document — (readonly) my signed address document as a blob
23
+ // pin_registrar — pin the host registrar's signing keys (pin-once)
24
+ // set_local_policy — toggle auto-accept of local introductions
25
+ // mint_introduction — registrar-only in practice: sign an introduction credential
26
+ // sign_book_entry — registrar-only in practice: sign a contact-book entry
27
+ // connect_local — register a book contact + send local_introduce (+ first message)
28
+ // approve_introduction — accept a pending local introduction (flushes its queue)
29
+ // reject_introduction — drop a pending local introduction
30
+ // list_pending_introductions — (readonly) pending introductions (names + queue sizes)
31
+ //
20
32
  // External transactions (inbound, not exposed as tools):
21
33
  // accept_contact — inviter learns the joiner's identity + name
22
34
  // receive_message — store a decrypted inbound message
35
+ // local_introduce — same-host peer connects via the local contact book
23
36
  //
24
37
  // Naming model (personal invites): generate_invite('Bob') tags a pending invite
25
38
  // with the peer-name "Bob"; whoever redeems it is registered under "Bob" (the
@@ -67,6 +80,38 @@ application actor loads libraries
67
80
  // migrates blobs in this shape forward — see below.
68
81
  metadef legacy_message_t: ($sender_id -> global_id, $sender_name -> str, $text -> str, $date -> time).
69
82
 
83
+ // ---- local contact book wire shapes ---------------------------------
84
+ // Introduction credential, minted PER CONNECT ATTEMPT by the host's
85
+ // registrar packet (never stored in the book). It binds the joiner's
86
+ // identity AND address document to one target, with freshness + a nonce,
87
+ // so possession of book material alone authorizes nothing: only the
88
+ // registrar (whose key never leaves the host) can mint one, which is
89
+ // what makes "local" a cryptographic property rather than a convention.
90
+ metadef intro_t: (
91
+ $version -> int,
92
+ $joiner_cid -> global_id,
93
+ $joiner_ad_hash -> hash_code,
94
+ $target_cid -> global_id,
95
+ $iat -> time,
96
+ $nonce -> global_id
97
+ ).
98
+ metadef signed_intro_t: ($i -> intro_t, $s -> crypto_signature).
99
+ // What the registrar signs for a contact-book entry (tamper-evidence for
100
+ // the host-side book file; verified by the SENDER in connect_local).
101
+ metadef book_entry_t: ($version -> int, $name -> str, $ad_hash -> hash_code).
102
+ // A not-yet-approved local introduction, with its bounded message queue.
103
+ metadef pending_msg_t: ($text -> str, $date -> time).
104
+ metadef pending_intro_t: ($name -> str, $ad -> address_document_types::t_address_document, $messages -> pending_msg_t[]).
105
+ metadef pending_view_t: ($name -> str, $queued -> int).
106
+
107
+ // Acceptance window for an introduction credential (seconds since mint;
108
+ // small negative slack for clock oddities) and the matching nonce-table
109
+ // retention horizon (window + slack, so a nonce outlives its credential).
110
+ intro_max_age_seconds is int = 300.
111
+ intro_max_skew_seconds is int = 30.
112
+ seen_nonce_cap is int = 1024.
113
+ pending_queue_cap is int = 50.
114
+
70
115
  // Wire the deserialization primitive into the libraries that need it.
71
116
  _read_or_abort = grab( _read_or_abort ).
72
117
  key_storage::init ($_read_or_abort -> _read_or_abort).
@@ -94,6 +139,20 @@ application actor loads libraries
94
139
  // every peer's keys in key_storage so encrypted channels survive the upgrade
95
140
  // with no re-handshake. Only peer PUBLIC keys travel here, never secrets.
96
141
  peer_ads is (global_id ->> address_document_types::t_address_document) = (,).
142
+ // The host registrar's address document (pinned once at identity
143
+ // creation / injected on upgrade) — its $identity $key_list is what
144
+ // introduction credentials are verified against. NIL means this identity
145
+ // accepts no local-book introductions at all.
146
+ registrar_ad is address_document_types::t_address_document+ = NIL.
147
+ // Whether a verified local introduction registers the joiner immediately
148
+ // (TRUE) or parks it in pending_introductions for explicit approval.
149
+ local_auto_accept is bool = TRUE.
150
+ // Replay guard for introduction credentials: nonce -> when it was seen.
151
+ // Lazily purged past the freshness horizon, hard-capped at seen_nonce_cap.
152
+ seen_nonces is (global_id ->> time) = (,).
153
+ // Verified-but-unapproved local introductions (when auto-accept is off),
154
+ // each with a bounded queue of messages awaiting approval.
155
+ pending_introductions is (global_id ->> pending_intro_t) = (,).
97
156
 
98
157
  // Signal the host to persist the packet. Only emitted at the end of a
99
158
  // complete procedure — intermediate states (e.g. channel handshake) are
@@ -114,6 +173,35 @@ application actor loads libraries
114
173
  abort "Unknown contact: " + ref when found == NIL.
115
174
  return found?.
116
175
  }
176
+
177
+ // Append a message to the inbox under a fresh id; returns the id.
178
+ fn deposit_message (sender_id: global_id, sender_name: str, text: str, msg_date: time) -> int
179
+ {
180
+ mid = next_msg_seq.
181
+ next_msg_seq -> next_msg_seq + 1.
182
+ inbox (_count inbox|) -> (
183
+ $msg_id -> mid,
184
+ $sender_id -> sender_id,
185
+ $sender_name -> sender_name,
186
+ $text -> text,
187
+ $date -> msg_date,
188
+ $status -> "unread"
189
+ ).
190
+ return mid.
191
+ }
192
+
193
+ // Resolve a pending introduction by joiner name or stringified container
194
+ // id; aborts when nothing matches.
195
+ fn resolve_pending (ref: str) -> global_id
196
+ {
197
+ found is global_id+ = NIL.
198
+ sc pending_introductions -- (cid -> p) ?? found == NIL && ((p $name) == ref || (_str cid) == ref)
199
+ {
200
+ found -> cid.
201
+ }
202
+ abort "No pending introduction matches: " + ref when found == NIL.
203
+ return found?.
204
+ }
117
205
  }
118
206
 
119
207
  // ---- user transactions --------------------------------------------------
@@ -394,6 +482,180 @@ application actor loads libraries
394
482
  ].
395
483
  }
396
484
 
485
+ // ---- local contact book ---------------------------------------------------
486
+ // The book itself lives HOST-SIDE (wrapper-local file, remote peers have no
487
+ // path to it) and stores only public address material — essentially a stored
488
+ // multi-use invite. It bypasses invite generation/delivery, NOT the key
489
+ // exchange: connecting still runs the normal encrypted_channel handshake.
490
+ // Authorization is per attempt: the host's registrar packet mints a fresh,
491
+ // short-lived, registrar-signed introduction credential for each connect, and
492
+ // the target verifies it against its pinned registrar keys. An external peer
493
+ // can never produce one, so the local boundary holds cryptographically.
494
+
495
+ trn readonly export_address_document _
496
+ {
497
+ return (_write address_document::get_my_address_document()).
498
+ }
499
+
500
+ trn pin_registrar _:($registrar_ad -> registrar_ad_blob: bin, $replace -> replace: bool+)
501
+ {
502
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
503
+
504
+ ad = (_read_or_abort registrar_ad_blob) safe address_document_types::t_address_document.
505
+ if registrar_ad != NIL
506
+ {
507
+ // Idempotent re-pin of the same keys is a no-op; CHANGING the pinned
508
+ // keys is a deliberate act and must be requested explicitly, so no
509
+ // future internal-path code can substitute a registrar silently.
510
+ if (_value_id (registrar_ad? $identity $key_list)) == (_value_id (ad $identity $key_list))
511
+ {
512
+ return transaction::success [
513
+ _return_data ($pinned -> TRUE, $changed -> FALSE)
514
+ ].
515
+ }
516
+ abort "A different registrar key list is already pinned; pass $replace -> TRUE to overwrite." when replace == NIL || replace? != TRUE.
517
+ }
518
+ registrar_ad -> ad.
519
+ return transaction::success [
520
+ _return_data ($pinned -> TRUE, $changed -> TRUE),
521
+ _save_state NIL
522
+ ].
523
+ }
524
+
525
+ trn set_local_policy _:($auto_accept -> auto_accept: bool)
526
+ {
527
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
528
+ local_auto_accept -> auto_accept.
529
+ return transaction::success [
530
+ _return_data ($auto_accept -> auto_accept),
531
+ _save_state NIL
532
+ ].
533
+ }
534
+
535
+ // Mint an introduction credential. Only meaningful on the host's REGISTRAR
536
+ // packet: targets verify the signature against their pinned registrar keys,
537
+ // so a credential minted by any other packet simply fails verification.
538
+ // Stateless — nothing to save. iat is stamped with transaction time so mint
539
+ // and verify use the same clock domain.
540
+ trn mint_introduction _:($joiner_ad -> joiner_ad_blob: bin, $target_ad -> target_ad_blob: bin)
541
+ {
542
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
543
+
544
+ joiner_ad = (_read_or_abort joiner_ad_blob) safe address_document_types::t_address_document.
545
+ target_ad = (_read_or_abort target_ad_blob) safe address_document_types::t_address_document.
546
+ intro is intro_t = (
547
+ $version -> 1,
548
+ $joiner_cid -> joiner_ad $identity $container_id,
549
+ $joiner_ad_hash -> _value_id joiner_ad,
550
+ $target_cid -> target_ad $identity $container_id,
551
+ $iat -> (current_transaction_info::get_transaction_time())?,
552
+ $nonce -> _new_id "a2adapt local introduction"
553
+ ).
554
+ signed is signed_intro_t = ($i -> intro, $s -> key_storage::default_sign (_value_id intro)).
555
+ return transaction::success [
556
+ _return_data ($intro -> (_write signed))
557
+ ].
558
+ }
559
+
560
+ // Sign a contact-book entry (registrar packet only, same caveat as above).
561
+ // Makes the host-side book file tamper-evident: senders re-derive this record
562
+ // from the entry they read and verify the signature before connecting.
563
+ trn sign_book_entry _:($name -> name: str, $ad -> ad_blob: bin)
564
+ {
565
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
566
+
567
+ ad = (_read_or_abort ad_blob) safe address_document_types::t_address_document.
568
+ entry is book_entry_t = ($version -> 1, $name -> name, $ad_hash -> _value_id ad).
569
+ return transaction::success [
570
+ _return_data ($sig -> (_write (key_storage::default_sign (_value_id entry))))
571
+ ].
572
+ }
573
+
574
+ // Connect to a same-host peer found in the local contact book: verify the
575
+ // book entry's registrar signature, register the peer as a contact, then
576
+ // introduce myself over the encrypted channel — carrying the credential the
577
+ // host just minted for this attempt, plus (optionally) the first message so
578
+ // introduction + first delivery are one atomic transaction on the target
579
+ // (no introduce-vs-message ordering race).
580
+ trn connect_local _:($name -> name: str, $target_ad -> target_ad_blob: bin, $intro -> intro_blob: bin, $entry_sig -> entry_sig_blob: bin, $text -> text: str+)
581
+ {
582
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
583
+ abort "No registrar pinned — the local contact book is unavailable for this identity." when registrar_ad == NIL.
584
+
585
+ target_ad = (_read_or_abort target_ad_blob) safe address_document_types::t_address_document.
586
+ target_id = target_ad $identity $container_id.
587
+ abort "This contact-book entry is your own identity." when target_id == _get_container_id().
588
+
589
+ entry is book_entry_t = ($version -> 1, $name -> name, $ad_hash -> _value_id target_ad).
590
+ entry_sig = (_read_or_abort entry_sig_blob) safe crypto_signature.
591
+ abort "Contact-book entry failed registrar verification." when key_storage::check_signature_new_container (_value_id entry) entry_sig (registrar_ad? $identity $key_list) != TRUE.
592
+
593
+ contacts target_id -> ($name -> name, $container_id -> target_id).
594
+ peer_ads target_id -> target_ad.
595
+
596
+ my_self_name = my_name.
597
+ my_ad = address_document::get_my_address_document().
598
+ return encrypted_channel::execute_transaction target_id (fn (_) -> transaction::results::type {
599
+ return transaction::success [
600
+ encrypted_channel::send_encrypted_tx target_id (
601
+ $name -> "::actor::local_introduce",
602
+ $targ -> ($joiner_name -> my_self_name, $joiner_ad -> my_ad, $intro -> intro_blob, $text -> text)
603
+ ),
604
+ _return_data ($connected -> name, $container_id -> target_id),
605
+ _save_state NIL
606
+ ].
607
+ }).
608
+ }
609
+
610
+ trn approve_introduction _:($contact -> ref: str)
611
+ {
612
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
613
+
614
+ pid = resolve_pending ref.
615
+ entry = (pending_introductions pid)?.
616
+ contacts pid -> ($name -> entry $name, $container_id -> pid).
617
+ peer_ads pid -> entry $ad.
618
+
619
+ queued = entry $messages.
620
+ flushed is int = 0.
621
+ sc queued -- ( -> m)
622
+ {
623
+ deposit_message pid (entry $name) (m $text) (m $date).
624
+ flushed -> flushed + 1.
625
+ }
626
+ delete pending_introductions pid.
627
+
628
+ return transaction::success [
629
+ _return_data ($approved -> entry $name, $container_id -> pid, $flushed -> flushed),
630
+ _save_state NIL
631
+ ].
632
+ }
633
+
634
+ trn reject_introduction _:($contact -> ref: str)
635
+ {
636
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
637
+
638
+ pid = resolve_pending ref.
639
+ entry = (pending_introductions pid)?.
640
+ dropped = _count (entry $messages)|.
641
+ delete pending_introductions pid.
642
+
643
+ return transaction::success [
644
+ _return_data ($rejected -> entry $name, $container_id -> pid, $dropped_messages -> dropped),
645
+ _save_state NIL
646
+ ].
647
+ }
648
+
649
+ trn readonly list_pending_introductions _
650
+ {
651
+ out is (global_id ->> pending_view_t) = (,).
652
+ sc pending_introductions -- (cid -> p)
653
+ {
654
+ out cid -> ($name -> p $name, $queued -> _count (p $messages)|).
655
+ }
656
+ return out.
657
+ }
658
+
397
659
  // ---- upgrade: state export / import -------------------------------------
398
660
  // The host persists state by calling export_state (readonly) and serializing
399
661
  // the returned value to a code-independent blob. On a code upgrade it recreates
@@ -406,12 +668,18 @@ application actor loads libraries
406
668
  trn readonly export_state _
407
669
  {
408
670
  return (
409
- $my_name -> my_name,
410
- $contacts -> contacts,
411
- $pending_invites -> pending_invites,
412
- $inbox -> inbox,
413
- $next_msg_seq -> next_msg_seq,
414
- $peer_ads -> peer_ads
671
+ $my_name -> my_name,
672
+ $contacts -> contacts,
673
+ $pending_invites -> pending_invites,
674
+ $inbox -> inbox,
675
+ $next_msg_seq -> next_msg_seq,
676
+ $peer_ads -> peer_ads,
677
+ $registrar_ad -> registrar_ad,
678
+ $local_auto_accept -> local_auto_accept,
679
+ // Nonces are exported so a restart does not reopen the replay window
680
+ // for still-fresh credentials; stale ones are purged lazily anyway.
681
+ $seen_nonces -> seen_nonces,
682
+ $pending_introductions -> pending_introductions
415
683
  ).
416
684
  }
417
685
 
@@ -476,6 +744,25 @@ application actor loads libraries
476
744
  next_msg_seq -> (data $next_msg_seq) safe int.
477
745
  }
478
746
 
747
+ // Local-contact-book state arrived after the original schema — every
748
+ // field is optional in old blobs and defaults stay in place when absent.
749
+ if (data $registrar_ad) != NIL
750
+ {
751
+ registrar_ad -> (data $registrar_ad) safe address_document_types::t_address_document.
752
+ }
753
+ if (data $local_auto_accept) != NIL
754
+ {
755
+ local_auto_accept -> (data $local_auto_accept) safe bool.
756
+ }
757
+ if (data $seen_nonces) != NIL
758
+ {
759
+ seen_nonces -> (data $seen_nonces) safe (global_id ->> time).
760
+ }
761
+ if (data $pending_introductions) != NIL
762
+ {
763
+ pending_introductions -> (data $pending_introductions) safe (global_id ->> pending_intro_t).
764
+ }
765
+
479
766
  // Re-register every peer's keys so encrypted channels keep working after
480
767
  // the upgrade — no handshake needed (my own keys are unchanged, and the
481
768
  // peers' self-signed address documents re-authorize on this fresh packet).
@@ -483,6 +770,12 @@ application actor loads libraries
483
770
  {
484
771
  address_document::process_address_document ad TRUE.
485
772
  }
773
+ // Pending introducers' keys too: their channel to me predates approval,
774
+ // so it must survive an upgrade exactly like an approved contact's.
775
+ sc pending_introductions -- ( -> p)
776
+ {
777
+ address_document::process_address_document (p $ad) TRUE.
778
+ }
486
779
 
487
780
  return transaction::success [
488
781
  _return_data ($imported -> TRUE, $contacts -> _count contacts|, $peers -> _count peer_ads|),
@@ -499,15 +792,18 @@ application actor loads libraries
499
792
 
500
793
  sender_id = current_transaction_info::get_external_envelope_or_abort() $from.
501
794
 
502
- // The name I assigned when I generated the invite wins; fall back to the
503
- // joiner's self-name if this invite is unknown (shouldn't happen).
795
+ // Only an invite I generated (and have not yet consumed) authorizes a
796
+ // contact registration. Without this gate any invite blob would be a
797
+ // multi-use bearer credential: anyone who ever saw one could register
798
+ // themselves as my contact with a self-chosen name.
504
799
  assigned_name = pending_invites invite_id.
505
- contact_name = (assigned_name == NIL ?? joiner_name ; assigned_name?).
800
+ abort "Unknown or already-redeemed invite." when assigned_name == NIL.
801
+ contact_name = assigned_name?.
506
802
 
507
803
  contacts sender_id -> ($name -> contact_name, $container_id -> sender_id).
508
804
  // Remember the joiner's address document for upgrade-time re-registration.
509
805
  peer_ads sender_id -> joiner_ad.
510
- if pending_invites invite_id != NIL { delete pending_invites invite_id. }
806
+ delete pending_invites invite_id.
511
807
 
512
808
  return transaction::success [
513
809
  _notify_agent ($event -> $contact_accepted, $name -> contact_name, $container_id -> sender_id),
@@ -521,23 +817,30 @@ application actor loads libraries
521
817
  encrypted_channel::check_encrypted_or_abort().
522
818
 
523
819
  sender_id = current_transaction_info::get_external_envelope_or_abort() $from.
820
+ msg_date = (current_transaction_info::get_transaction_time())?.
524
821
  sender = contacts sender_id.
525
- abort "Message from an unknown sender was rejected." when sender == NIL.
822
+
823
+ if sender == NIL
824
+ {
825
+ // A verified-but-unapproved local introduction may message me before
826
+ // approval: queue (bounded) inside its pending entry. Approval
827
+ // flushes the queue into the inbox in order; anything else from an
828
+ // unknown sender is rejected as before.
829
+ p = pending_introductions sender_id.
830
+ abort "Message from an unknown sender was rejected." when p == NIL.
831
+ entry = p?.
832
+ queued = entry $messages.
833
+ abort "Pending-introduction message queue is full; awaiting approval." when (_count queued|) >= pending_queue_cap.
834
+ queued (_count queued|) -> ($text -> text, $date -> msg_date).
835
+ pending_introductions sender_id -> ($name -> entry $name, $ad -> entry $ad, $messages -> queued).
836
+ return transaction::success [
837
+ _notify_agent ($event -> $pending_message, $sender_name -> entry $name, $queued -> _count queued|),
838
+ _save_state NIL
839
+ ].
840
+ }
526
841
 
527
842
  sender_name = sender? $name.
528
- msg_date = current_transaction_info::get_transaction_time().
529
-
530
- mid = next_msg_seq.
531
- next_msg_seq -> next_msg_seq + 1.
532
-
533
- inbox (_count inbox|) -> (
534
- $msg_id -> mid,
535
- $sender_id -> sender_id,
536
- $sender_name -> sender_name,
537
- $text -> text,
538
- $date -> msg_date,
539
- $status -> "unread"
540
- ).
843
+ mid = deposit_message sender_id sender_name text msg_date.
541
844
 
542
845
  // The notification deliberately carries NO message body — only that a
543
846
  // message arrived, from whom, and its id. The body stays in the packet
@@ -547,4 +850,97 @@ application actor loads libraries
547
850
  _save_state NIL
548
851
  ].
549
852
  }
853
+
854
+ // A same-host peer connects via the local contact book. The credential must
855
+ // have been minted by THIS HOST's registrar for THIS sender and THIS target,
856
+ // recently, and never seen before — five checks that together make the book
857
+ // path unusable from outside the host:
858
+ // 1. registrar signature over the credential (pinned key list)
859
+ // 2. envelope $from == credential.joiner_cid (no splicing someone
860
+ // else's credential onto your own channel)
861
+ // 3. hash(joiner_ad) == credential.joiner_ad_hash (no AD substitution)
862
+ // 4. credential.target_cid == me (no cross-target reuse)
863
+ // 5. freshness window + unseen nonce (no replay)
864
+ // The encrypted channel itself authenticates that the sender controls its
865
+ // keys, so no extra challenge-response is needed.
866
+ trn local_introduce _:($joiner_name -> joiner_name: str, $joiner_ad -> joiner_ad: address_document_types::t_address_document, $intro -> intro_blob: bin, $text -> text: str+)
867
+ {
868
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::external,).
869
+ encrypted_channel::check_encrypted_or_abort().
870
+
871
+ sender_id = current_transaction_info::get_external_envelope_or_abort() $from.
872
+ abort "This identity does not accept local-contact-book introductions." when registrar_ad == NIL.
873
+
874
+ signed = (_read_or_abort intro_blob) safe signed_intro_t.
875
+ intro = signed $i.
876
+ abort "Unsupported introduction credential version." when (intro $version) != 1.
877
+ abort "Introduction credential was not signed by this host's registrar." when key_storage::check_signature_new_container (_value_id intro) (signed $s) (registrar_ad? $identity $key_list) != TRUE.
878
+ abort "Introduction credential was minted for a different sender." when (intro $joiner_cid) != sender_id.
879
+ abort "Introduction credential does not match the sender's address document." when (intro $joiner_ad_hash) != _value_id joiner_ad.
880
+ abort "Introduction credential targets a different identity." when (intro $target_cid) != _get_container_id().
881
+
882
+ now = (current_transaction_info::get_transaction_time())?.
883
+ age = _substract_seconds now (intro $iat).
884
+ abort "Introduction credential is outside its freshness window." when age > intro_max_age_seconds || age < (0 - intro_max_skew_seconds).
885
+
886
+ // Lazy nonce GC (drop everything past the retention horizon), then the
887
+ // replay check, then a hard cap so a misbehaving local peer cannot bloat
888
+ // packet state inside the window.
889
+ horizon = intro_max_age_seconds + intro_max_skew_seconds.
890
+ fresh_nonces is (global_id ->> time) = (,).
891
+ sc seen_nonces -- (n -> t)
892
+ {
893
+ if (_substract_seconds now t) <= horizon { fresh_nonces n -> t. }
894
+ }
895
+ seen_nonces -> fresh_nonces.
896
+ abort "Replayed introduction credential." when seen_nonces (intro $nonce) != NIL.
897
+ abort "Too many concurrent introductions; try again shortly." when (_count seen_nonces|) >= seen_nonce_cap.
898
+ seen_nonces (intro $nonce) -> now.
899
+
900
+ existing = contacts sender_id.
901
+ if existing != NIL
902
+ {
903
+ // Already a contact (idempotent re-introduction): keep my assigned
904
+ // name, refresh the stored address document, deliver any payload.
905
+ peer_ads sender_id -> joiner_ad.
906
+ if text != NIL
907
+ {
908
+ mid = deposit_message sender_id (existing? $name) text? now.
909
+ return transaction::success [
910
+ _notify_agent ($event -> $message_received, $sender_name -> existing? $name, $msg_id -> mid, $date -> now),
911
+ _save_state NIL
912
+ ].
913
+ }
914
+ return transaction::success [ _save_state NIL ].
915
+ }
916
+
917
+ if local_auto_accept
918
+ {
919
+ contacts sender_id -> ($name -> joiner_name, $container_id -> sender_id).
920
+ peer_ads sender_id -> joiner_ad.
921
+ if text != NIL
922
+ {
923
+ mid = deposit_message sender_id joiner_name text? now.
924
+ return transaction::success [
925
+ _notify_agent ($event -> $local_contact_added, $name -> joiner_name, $container_id -> sender_id),
926
+ _notify_agent ($event -> $message_received, $sender_name -> joiner_name, $msg_id -> mid, $date -> now),
927
+ _save_state NIL
928
+ ].
929
+ }
930
+ return transaction::success [
931
+ _notify_agent ($event -> $local_contact_added, $name -> joiner_name, $container_id -> sender_id),
932
+ _save_state NIL
933
+ ].
934
+ }
935
+
936
+ // Pending-approval policy: park the introduction (with its optional
937
+ // first message) until approve_introduction / reject_introduction.
938
+ queued is pending_msg_t[] = [].
939
+ if text != NIL { queued 0 -> ($text -> text?, $date -> now). }
940
+ pending_introductions sender_id -> ($name -> joiner_name, $ad -> joiner_ad, $messages -> queued).
941
+ return transaction::success [
942
+ _notify_agent ($event -> $local_contact_request, $name -> joiner_name, $container_id -> sender_id, $queued -> _count queued|),
943
+ _save_state NIL
944
+ ].
945
+ }
550
946
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adapt-toolkit/a2adapt",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "MCP server daemon for a2adapt — one native ADAPT wrapper hosting N self-sovereign identities, exposing secure agent-to-agent messaging tools over HTTP (Streamable HTTP). Run `a2adapt-mcp start`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,8 +49,8 @@
49
49
  "typecheck": "tsc -p tsconfig.json --noEmit"
50
50
  },
51
51
  "dependencies": {
52
- "@adapt-toolkit/sdk": "^0.2.4",
53
- "@adapt-toolkit/sdk-native": "^0.2.3"
52
+ "@adapt-toolkit/sdk": "^0.4.0",
53
+ "@adapt-toolkit/sdk-native": "^0.4.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@modelcontextprotocol/sdk": "^1.0.4",