@joe-broadhead/messaging 0.7.1 → 0.8.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 (89) hide show
  1. package/CHANGELOG.md +214 -2
  2. package/dist/bin/messaging-mcp.js +5043 -4054
  3. package/dist/bin/messaging-relay.js +8263 -5425
  4. package/dist/bin/messaging.js +5982 -4906
  5. package/dist/bin/messagingd.js +19956 -13716
  6. package/dist/lib/modules/core/agent/operations.js +19 -327
  7. package/dist/lib/modules/core/agent/present.js +15 -55
  8. package/dist/lib/modules/core/agent-tools.js +23 -139
  9. package/dist/lib/modules/core/consent.js +1 -1
  10. package/dist/lib/modules/core/conversation/event.js +29 -4
  11. package/dist/lib/modules/core/conversation/guest-invitation.js +42 -0
  12. package/dist/lib/modules/core/conversation/log.js +287 -65
  13. package/dist/lib/modules/core/conversation/request-log.js +158 -0
  14. package/dist/lib/modules/core/conversation/request-seal.js +41 -0
  15. package/dist/lib/modules/core/conversation/requests.js +87 -0
  16. package/dist/lib/modules/core/conversation/seal.js +90 -37
  17. package/dist/lib/modules/core/conversation-agent-tools.js +69 -0
  18. package/dist/lib/modules/core/federation/directory-proof.js +83 -0
  19. package/dist/lib/modules/core/federation/proof-state.js +167 -0
  20. package/dist/lib/modules/core/federation/roster-proof.js +74 -0
  21. package/dist/lib/modules/core/index.js +8 -3
  22. package/dist/lib/modules/core/jelly-host.js +5 -5
  23. package/dist/lib/modules/core/schema/common.js +31 -5
  24. package/dist/lib/modules/core/schema/consent.js +5 -4
  25. package/dist/lib/modules/core/schema/contact-request.js +22 -0
  26. package/dist/lib/modules/core/schema/conv-frames.js +94 -0
  27. package/dist/lib/modules/core/schema/conversation.js +67 -33
  28. package/dist/lib/modules/core/schema/envelope.js +8 -24
  29. package/dist/lib/modules/core/schema/errors.js +4 -3
  30. package/dist/lib/modules/core/schema/guest-invitation.js +13 -0
  31. package/dist/lib/modules/core/schema/index.js +5 -0
  32. package/dist/lib/modules/core/schema/ipc/consent.js +13 -9
  33. package/dist/lib/modules/core/schema/ipc/conversation-views.js +80 -0
  34. package/dist/lib/modules/core/schema/ipc/conversations.js +128 -0
  35. package/dist/lib/modules/core/schema/ipc/device.js +2 -0
  36. package/dist/lib/modules/core/schema/ipc/jelly.js +3 -38
  37. package/dist/lib/modules/core/schema/ipc/shapes.js +14 -72
  38. package/dist/lib/modules/core/schema/ipc/surfaces.js +12 -195
  39. package/dist/lib/modules/core/schema/ipc/tasks.js +31 -0
  40. package/dist/lib/modules/core/schema/ipc.js +18 -44
  41. package/dist/lib/modules/core/testing.js +1 -1
  42. package/dist/lib/modules/core/version.js +1 -1
  43. package/dist/lib/modules/ipc/client.js +10 -2
  44. package/dist/lib/modules/ipc/server.js +14 -5
  45. package/dist/lib/modules/ipc/testing.js +13 -23
  46. package/dist/spec/messaging-v1.md +546 -409
  47. package/dist/third-party-notices.md +1 -1
  48. package/dist/third-party.json +1 -1
  49. package/dist/types/core/agent/operations.d.ts +5 -52
  50. package/dist/types/core/agent/present.d.ts +6 -21
  51. package/dist/types/core/agent-tools.d.ts +263 -442
  52. package/dist/types/core/conversation/event.d.ts +6 -6
  53. package/dist/types/core/conversation/guest-invitation.d.ts +8 -0
  54. package/dist/types/core/conversation/log.d.ts +28 -13
  55. package/dist/types/core/conversation/request-log.d.ts +24 -0
  56. package/dist/types/core/conversation/request-seal.d.ts +6 -0
  57. package/dist/types/core/conversation/requests.d.ts +14 -0
  58. package/dist/types/core/conversation/seal.d.ts +32 -9
  59. package/dist/types/core/conversation-agent-tools.d.ts +918 -0
  60. package/dist/types/core/federation/directory-proof.d.ts +8 -0
  61. package/dist/types/core/federation/proof-state.d.ts +114 -0
  62. package/dist/types/core/federation/roster-proof.d.ts +7 -0
  63. package/dist/types/core/index.d.ts +10 -5
  64. package/dist/types/core/jelly-host.d.ts +1 -1
  65. package/dist/types/core/schema/common.d.ts +31 -6
  66. package/dist/types/core/schema/consent.d.ts +7 -7
  67. package/dist/types/core/schema/contact-request.d.ts +264 -0
  68. package/dist/types/core/schema/conv-frames.d.ts +1200 -0
  69. package/dist/types/core/schema/conversation.d.ts +1410 -264
  70. package/dist/types/core/schema/envelope.d.ts +83 -130
  71. package/dist/types/core/schema/errors.d.ts +3 -1
  72. package/dist/types/core/schema/guest-invitation.d.ts +51 -0
  73. package/dist/types/core/schema/index.d.ts +5 -0
  74. package/dist/types/core/schema/ipc/consent.d.ts +89 -49
  75. package/dist/types/core/schema/ipc/conversation-views.d.ts +1023 -0
  76. package/dist/types/core/schema/ipc/conversations.d.ts +3987 -0
  77. package/dist/types/core/schema/ipc/device.d.ts +4 -0
  78. package/dist/types/core/schema/ipc/jelly.d.ts +2 -105
  79. package/dist/types/core/schema/ipc/shapes.d.ts +13 -95
  80. package/dist/types/core/schema/ipc/surfaces.d.ts +25 -427
  81. package/dist/types/core/schema/ipc/tasks.d.ts +208 -0
  82. package/dist/types/core/schema/ipc.d.ts +2671 -525
  83. package/dist/types/core/schema/relay.d.ts +8 -4
  84. package/dist/types/core/testing.d.ts +1 -1
  85. package/dist/types/core/version.d.ts +1 -1
  86. package/dist/types/ipc/client.d.ts +4 -1
  87. package/dist/types/ipc/server.d.ts +10 -1
  88. package/dist/types/ipc/testing.d.ts +21 -8
  89. package/package.json +1 -1
@@ -0,0 +1,167 @@
1
+ /** Internal X3-3 boundary. No discovery response is a pin and no result is human approval. */
2
+ import { canonicalize } from "../jcs.js";
3
+ import { sha256 } from "../hash.js";
4
+ import { LIMITS } from "../schema/common.js";
5
+ export const FOREIGN_LIMITS = { fetchMs: 10 * 60_000, headMs: 20 * 60_000, futureMs: LIMITS.clockSkewMs };
6
+ /** Internal work budgets, independent of transport limits. `heads` bounds the recent-head
7
+ * working window only; the caller's durable archive retains observations beyond it. */
8
+ export const FOREIGN_BUDGET = { proofBytes: 1_048_576, stateBytes: 4_194_304, nodes: 32_768, depth: 32, rosterEntries: 1024, rotations: 64, consistency: 64, heads: 1024 };
9
+ export class ForeignInputError extends Error {
10
+ constructor() { super("foreign-input-limit"); }
11
+ }
12
+ /** Bound traversal before canonicalization allocates/sorts the complete value. No accessors run. */
13
+ export function checkForeignValue(value, maxBytes = FOREIGN_BUDGET.proofBytes) {
14
+ let bytes = 0, nodes = 0;
15
+ const maxNodes = FOREIGN_BUDGET.nodes * Math.max(1, maxBytes / FOREIGN_BUDGET.proofBytes);
16
+ const add = (size) => { bytes += size; if (bytes > maxBytes)
17
+ throw new ForeignInputError(); };
18
+ const text = (s) => {
19
+ if (s.length > maxBytes - bytes)
20
+ throw new ForeignInputError();
21
+ add(Buffer.byteLength(JSON.stringify(s), "utf8"));
22
+ };
23
+ const visit = (item, depth) => {
24
+ if (++nodes > maxNodes)
25
+ throw new ForeignInputError();
26
+ if (item === null) {
27
+ add(4);
28
+ return;
29
+ }
30
+ if (typeof item === "string") {
31
+ text(item);
32
+ return;
33
+ }
34
+ if (typeof item === "boolean") {
35
+ add(item ? 4 : 5);
36
+ return;
37
+ }
38
+ if (typeof item === "number" && Number.isFinite(item)) {
39
+ add(String(item).length);
40
+ return;
41
+ }
42
+ if (typeof item !== "object" || depth >= FOREIGN_BUDGET.depth)
43
+ throw new ForeignInputError();
44
+ add(2);
45
+ if (Array.isArray(item)) {
46
+ if (item.length > maxNodes - nodes)
47
+ throw new ForeignInputError();
48
+ for (let i = 0; i < item.length; i++) {
49
+ const field = Object.getOwnPropertyDescriptor(item, String(i));
50
+ if (!field || !("value" in field))
51
+ throw new ForeignInputError();
52
+ if (i)
53
+ add(1);
54
+ visit(field.value, depth + 1);
55
+ }
56
+ return;
57
+ }
58
+ const proto = Object.getPrototypeOf(item);
59
+ if (proto !== Object.prototype && proto !== null && Object.getPrototypeOf(proto) !== null)
60
+ throw new ForeignInputError();
61
+ let count = 0;
62
+ for (const key in item) {
63
+ if (!Object.hasOwn(item, key))
64
+ continue;
65
+ if (++nodes > maxNodes)
66
+ throw new ForeignInputError();
67
+ const field = Object.getOwnPropertyDescriptor(item, key);
68
+ if (!("value" in field))
69
+ throw new ForeignInputError();
70
+ if (field.value === undefined)
71
+ continue;
72
+ if (count++)
73
+ add(1);
74
+ text(key);
75
+ add(1);
76
+ visit(field.value, depth + 1);
77
+ }
78
+ };
79
+ visit(value, 0);
80
+ }
81
+ export function admitForeignProof(proof, kind) {
82
+ checkForeignValue(proof);
83
+ if (typeof proof !== "object" || proof === null)
84
+ return; // The verifier owns schema errors.
85
+ const bundle = proof;
86
+ const collection = (name, max) => {
87
+ const entries = bundle[name];
88
+ if (Array.isArray(entries) && entries.length > max)
89
+ throw new ForeignInputError();
90
+ };
91
+ if (kind === "roster") {
92
+ collection("entries", FOREIGN_BUDGET.rosterEntries);
93
+ if (Array.isArray(bundle.entries))
94
+ for (const entry of bundle.entries)
95
+ checkForeignValue(entry, LIMITS.rosterEntryBytes);
96
+ }
97
+ else {
98
+ collection("rotations", FOREIGN_BUDGET.rotations);
99
+ collection("consistency", FOREIGN_BUDGET.consistency);
100
+ }
101
+ }
102
+ export class ForeignProofError extends Error {
103
+ reason;
104
+ code;
105
+ compatible;
106
+ constructor(reason, code, compatible = false) {
107
+ super(code);
108
+ this.reason = reason;
109
+ this.code = code;
110
+ this.compatible = compatible;
111
+ }
112
+ }
113
+ export function requireProof(condition, code, reason = "invalid", compatible = false) {
114
+ if (!condition)
115
+ throw new ForeignProofError(reason, code, compatible);
116
+ }
117
+ export function proofDigest(value) { checkForeignValue(value); return sha256(canonicalize(value)); }
118
+ export function proofFailure(error, proof) {
119
+ let digest;
120
+ if (error instanceof ForeignInputError)
121
+ return { ok: false, reason: "invalid", evidence: { code: "foreign-input-limit", digest: sha256("foreign-input-limit") } };
122
+ try {
123
+ digest = proofDigest(proof);
124
+ }
125
+ catch {
126
+ digest = sha256("non-json-proof");
127
+ }
128
+ return { ok: false, reason: error instanceof ForeignProofError && error.reason !== "stale" ? error.reason : "invalid", evidence: { code: error instanceof ForeignProofError ? error.code : "invalid-proof", digest, ...(error instanceof ForeignProofError && error.reason === "rollback" && error.compatible ? { compatible: true } : {}) } };
129
+ }
130
+ /** Expiry changes key eligibility, never the authenticated history we must retain. */
131
+ export function verifiedProof(verified, now) {
132
+ try {
133
+ checkHeadTime(verified.realm.head, now);
134
+ }
135
+ catch (error) {
136
+ if (!(error instanceof ForeignProofError) || error.reason !== "stale")
137
+ throw error;
138
+ return { ok: false, reason: "stale", verified, evidence: { code: error.code, digest: verified.identity.proofDigest } };
139
+ }
140
+ return { ok: true, ...verified };
141
+ }
142
+ export function checkContext(input, kind) {
143
+ requireProof(Number.isSafeInteger(input.now) && input.now >= 0, "clock");
144
+ requireProof(input.pin.kind === kind && input.subject.realm === input.pin.realm, "pinned-realm", "root");
145
+ if (input.realm)
146
+ requireProof(input.realm.realm === input.pin.realm && input.realm.pinDigest === proofDigest(input.pin), "known-root", "root");
147
+ if (input.known)
148
+ requireProof(input.known.subject.realm === input.subject.realm && input.known.subject.person === input.subject.person, "known-person");
149
+ }
150
+ export function checkHeadTime(head, now) {
151
+ requireProof(Number.isSafeInteger(head.issuedAt) && Number.isSafeInteger(head.expiresAt), "head-time");
152
+ requireProof(head.issuedAt <= now + FOREIGN_LIMITS.futureMs, "future-head");
153
+ requireProof(head.expiresAt > head.issuedAt && head.expiresAt - head.issuedAt <= FOREIGN_LIMITS.headMs, "head-lifetime");
154
+ requireProof(now - head.issuedAt <= FOREIGN_LIMITS.headMs && now < head.expiresAt, "expired-head", "stale");
155
+ }
156
+ /** Caller clock only. A future local fetch time (including clock rollback) fails closed. */
157
+ export function foreignFresh(head, fetchedAt, now) {
158
+ if (!Number.isSafeInteger(now) || !Number.isSafeInteger(fetchedAt) || fetchedAt > now || now - fetchedAt > FOREIGN_LIMITS.fetchMs)
159
+ return false;
160
+ try {
161
+ checkHeadTime(head, now);
162
+ return true;
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
@@ -0,0 +1,74 @@
1
+ import { z } from "../../vendor/zod/index.js";
2
+ import { parseWith } from "../check.js";
3
+ import { Roster, entryHash } from "../roster.js";
4
+ import { Hash, MachineId, Millis, PersonName } from "../schema/common.js";
5
+ import { verify } from "../sign.js";
6
+ import { admitForeignProof, checkContext, proofDigest, proofFailure, requireProof, verifiedProof } from "./proof-state.js";
7
+ /** Internal until X3-2/3 supplies the normative contract. */
8
+ export const DOMAIN = { rosterCheckpoint: "messaging/1/federation/roster-checkpoint\n" };
9
+ const Checkpoint = z.strictObject({ realm: Hash, seq: z.number().int().nonnegative(), hash: Hash, issuedAt: Millis, expiresAt: Millis, by: MachineId, sig: z.string() });
10
+ const Bundle = z.strictObject({ entries: z.array(z.unknown()).min(1), checkpoint: z.unknown() });
11
+ /** Full history only; the caller has separately authorized its disclosure. */
12
+ export function verifyForeignRoster(input) {
13
+ try {
14
+ admitForeignProof(input.proof, "roster");
15
+ checkContext(input, "roster");
16
+ parseWith(PersonName, input.subject.person, "foreign person");
17
+ const pin = parseWith(z.strictObject({ genesis: Hash }), input.pin.root, "roster pin");
18
+ requireProof(pin.genesis === input.pin.realm, "genesis-pin", "root");
19
+ const bundle = parseWith(Bundle, input.proof, "roster proof");
20
+ // Roster retains and verifies raw signed entries, including unknown signed fields.
21
+ const roster = Roster.fromEntries(bundle.entries);
22
+ requireProof(roster.realm === pin.genesis, "genesis-root", "root");
23
+ const raw = bundle.checkpoint;
24
+ requireProof(typeof raw === "object" && raw !== null, "checkpoint-shape");
25
+ const { sig, ...unsigned } = raw;
26
+ requireProof(typeof raw.by === "string" && typeof sig === "string" && verify(raw.by, DOMAIN.rosterCheckpoint, unsigned, sig), "checkpoint-signature");
27
+ const checkpoint = parseWith(Checkpoint, raw, "roster checkpoint");
28
+ const current = roster.head();
29
+ requireProof(checkpoint.realm === input.pin.realm, "checkpoint-realm");
30
+ requireProof(checkpoint.seq === current.seq && checkpoint.hash === current.hash, "checkpoint-head");
31
+ requireProof(roster.isAdmin(checkpoint.by), "checkpoint-admin");
32
+ // Compare every available common prefix before classifying an older response as rollback.
33
+ // The realm cursor also protects persons for whom we have never retained a separate view.
34
+ const hashes = roster.entries().map(entryHash);
35
+ for (const held of [input.realm, input.known]) {
36
+ if (!held)
37
+ continue;
38
+ const position = Math.min(current.seq, held.head.position);
39
+ const expected = position === held.head.position ? held.head.hash : held.cursor.hashes[position];
40
+ requireProof(hashes[position] === expected, "roster-fork", "fork");
41
+ }
42
+ for (const held of [input.realm?.head, input.known?.head]) {
43
+ if (!held)
44
+ continue;
45
+ requireProof(current.seq >= held.position, "roster-rollback", "rollback", true);
46
+ requireProof(checkpoint.issuedAt >= held.issuedAt, "checkpoint-rollback", "rollback", true);
47
+ }
48
+ const head = { position: current.seq, hash: current.hash, issuedAt: checkpoint.issuedAt, expiresAt: checkpoint.expiresAt };
49
+ const removed = new Set();
50
+ const people = new Map();
51
+ const reviews = new Map();
52
+ const versions = new Map();
53
+ for (const entry of roster.entries()) {
54
+ const member = entry.kind === "genesis" ? entry.body.founder : entry.kind === "member.add" ? entry.body : entry.kind === "invite.redeem" ? entry.body.member : undefined;
55
+ if (member)
56
+ people.set(member.machine, member.person);
57
+ if (entry.kind === "member.remove" && people.get(entry.body.machine) === input.subject.person)
58
+ removed.add(entry.body.machine);
59
+ if (entry.kind === "member.rekey") {
60
+ versions.set(entry.body.machine, entry.seq);
61
+ if (entry.by !== entry.body.machine)
62
+ reviews.set(entry.body.machine, entryHash(entry));
63
+ }
64
+ }
65
+ const devices = roster.members().filter((member) => member.person === input.subject.person).map((member) => ({ signingKey: member.machine, encryptionKey: member.enc, keyVersion: versions.get(member.machine) ?? 0, review: reviews.get(member.machine) ?? null }));
66
+ return verifiedProof({
67
+ realm: { realm: input.pin.realm, pinDigest: proofDigest(input.pin), head, cursor: { hashes } },
68
+ identity: { subject: { ...input.subject }, head, devices, removed: [...removed], erased: false, proofDigest: proofDigest(input.proof), cursor: { hashes } },
69
+ }, input.now);
70
+ }
71
+ catch (error) {
72
+ return proofFailure(error, input.proof);
73
+ }
74
+ }
@@ -1,10 +1,15 @@
1
1
  // Wire contracts (frozen; change only with a spec update and new test vectors).
2
2
  export * from "./schema/index.js";
3
+ export * from "./conversation-agent-tools.js";
3
4
  // Protocol logic; see docs/spec/messaging-v1.md §9.
4
5
  export { b64u } from "./b64u.js";
5
6
  export { VERSION } from "./version.js";
6
7
  export { canonicalize } from "./jcs.js";
7
8
  export { parseWire } from "./wire.js";
9
+ export * from "./conversation/requests.js";
10
+ export * from "./conversation/request-log.js";
11
+ export * from "./conversation/request-seal.js";
12
+ export * from "./conversation/guest-invitation.js";
8
13
  export { sha256 } from "./hash.js";
9
14
  export { ulid } from "./ulid.js";
10
15
  export { Identity, IdentityFile, generateIdentity, exportIdentity, importIdentity } from "./identity.js";
@@ -37,17 +42,17 @@ export { canTransition, isTerminal, isInterrupted } from "./task.js";
37
42
  export { TERMINAL_STATES } from "./schema/a2a.js";
38
43
  export { cleanPeerText, createFence, terminalSafe, displayWidth, HOST_TAGS, PEER_FENCE_TAG, PEER_DATA_NOTICE, provenanceOf } from "./peer-text.js";
39
44
  // The agent tool contract shared by the OpenCode plugin and messaging-mcp (spec §11, "Agent tools").
40
- export { AgentTool, TOOL_VERBS, TOOL_DESCRIPTIONS, ToolExpect, UNTRUSTED_SENTENCE, TOOL_REMEDIES, STORE_REFUSED_REMEDIES, DONE_STATES, TaskStateName, ToolErrorBody, ToolFiles, a2aTaskState, conversationOf, decodeToolCursor, encodeToolCursor, formatToolAddress, parseToolAddress, taskStateName, toolErrorBody, toolName, } from "./agent-tools.js";
45
+ export { AgentTool, TOOL_VERBS, TOOL_DESCRIPTIONS, ToolExpect, UNTRUSTED_SENTENCE, TOOL_REMEDIES, STORE_REFUSED_REMEDIES, DONE_STATES, TaskStateName, ToolErrorBody, ToolFiles, a2aTaskState, formatToolAddress, parseToolAddress, taskStateName, toolErrorBody, toolName, } from "./agent-tools.js";
41
46
  // The agent tools' operations and presenters, shared by the plugin and messaging-mcp (spec §11).
42
47
  export { Operations, } from "./agent/operations.js";
43
- export { agentCan, consentOf, conversationOfPeer, followUpRead, isoOf, presentAgent, presentConvEntry, presentInboxItem, presentLine, presentMail, presentSent, presentTask, presentTaskItem, presentTaskView, } from "./agent/present.js";
48
+ export { agentCan, consentOf, followUpRead, isoOf, presentAgent, presentInboxItem, presentTaskEnvelope, presentTask, presentTaskItem, presentTaskView, } from "./agent/present.js";
44
49
  export { chainPath, compact, delegated, delegationPrompt, EXPECT_TEXT, isSafeId, messageAttributes, validatedAddress, } from "./agent/text.js";
45
50
  // Conversations (spec §16): ids, events and the log's hash chain, sender keys and sealing, the log as a pure
46
51
  // reducer, and context-rule resolution. Fixed-randomness sealing for vectors lives in `@messaging/core/testing`.
47
52
  export { dmConvId, selfConvId, participantOf, participantPerson, participantAgent } from "./conversation/ids.js";
48
53
  export { admitConvEvent, convEntryHash, convEventDigest, convEventHeader, convEventSigned, convGenesisHash, convLinkHash, signConvEvent, verifyConvEvent, } from "./conversation/event.js";
49
54
  export { chainStep, ReceiverChains, ReceiverChainsState, SenderChain, SenderChainState } from "./conversation/ratchet.js";
50
- export { openConvMeta, openConvMsg, openKeyShares, sealConvMeta, sealConvMsg, sealKeyShares, } from "./conversation/seal.js";
55
+ export { openConvMeta, openConvMsg, openConvState, openKeyShares, sealConvMeta, sealConvMsg, sealConvState, sealKeyShare, } from "./conversation/seal.js";
51
56
  export { ConvLog, } from "./conversation/log.js";
52
57
  export { checkVia, currentLinks, linkReader } from "./conversation/link.js";
53
58
  export { CONTEXT_DEFAULTS, contextKindOf, matchContextRule, resolveContext, seatReads, } from "./conversation/context.js";
@@ -22,7 +22,7 @@ export const SESSION_TICKET_MAX_S = 7 * 24 * 60 * 60;
22
22
  /** The clock skew allowed on `iat` and `exp`. */
23
23
  export const JELLY_HOST_SKEW_S = 5;
24
24
  /** `chat`: the person's own lines and read state. */
25
- const CHAT_METHODS = ["person.send", "conv.markRead", "notice.ack"];
25
+ const CHAT_METHODS = ["conv.send", "conv.edit", "conv.delete", "conv.react", "notice.ack"];
26
26
  /**
27
27
  * `view`: the read-only methods a Jelly section reads with (`section.rpc`): the read-only human-tier methods, and
28
28
  * `device.list`, the one admin-tier read a section shows. A preview is not one of them: it takes its action's class
@@ -32,13 +32,13 @@ const CHAT_METHODS = ["person.send", "conv.markRead", "notice.ack"];
32
32
  const VIEW_METHODS = [
33
33
  "conv.list",
34
34
  "conv.read",
35
- "conv.subscribe",
35
+ "conv.sync", "conv.get", "conv.members", "conv.threads", "conv.search", "conv.context", "conv.access",
36
+ "request.waiting",
36
37
  "people.search",
37
38
  "people.get",
38
39
  "grant.list",
39
40
  "request.list",
40
- "agents.list",
41
- "agents.read",
41
+ "sessions.list",
42
42
  "notice.subscribe",
43
43
  "notice.list",
44
44
  "device.list",
@@ -46,7 +46,7 @@ const VIEW_METHODS = [
46
46
  const classOf = (method) => CHAT_METHODS.includes(method) ? "chat" : VIEW_METHODS.includes(method) ? "view" : "local";
47
47
  /**
48
48
  * The class a method grant must name in `cls` for each human- and admin-tier method (spec §8.11): `chat` for
49
- * `person.send`, `conv.markRead` and `notice.ack`, `view` for the read-only human-tier methods, `local` for every
49
+ * line mutations and `notice.ack`, `view` for the read-only human-tier methods, `local` for every
50
50
  * other. A plugin's `jelly` declaration gives each action and read the same class; a test there checks one against
51
51
  * the other.
52
52
  */
@@ -54,6 +54,12 @@ export const DOMAIN = {
54
54
  convMsg: "messaging/1/conv-msg",
55
55
  /** Conversations (spec §16.5): HKDF `info` for a sender chain's next chain key. */
56
56
  convChain: "messaging/1/conv-chain",
57
+ request: "messaging/1/contact-request\n",
58
+ requestContent: "messaging/1/contact-request-content\n",
59
+ requestDigest: "messaging/1/contact-request-digest\n",
60
+ requestDecision: "messaging/1/contact-request-decision\n",
61
+ contactAuthorization: "messaging/1/contact-authorization\n",
62
+ guestInvitation: "messaging/1/guest-invitation\n",
57
63
  };
58
64
  /** A2A extension and binding URIs owned by Messaging. */
59
65
  export const URI = {
@@ -81,8 +87,6 @@ export const HostedPersonId = z.string().regex(/^p_[a-z2-7]{26}$/, "hosted perso
81
87
  export const PersonId = z.union([PersonName, HostedPersonId]);
82
88
  /** A channel's name (spec §6.8). Unique among the channels one machine holds, not across the team. */
83
89
  export const ChannelName = z.string().regex(/^[a-z][a-z0-9-]{0,31}$/, "lowercase channel name");
84
- /** A chat room (spec §6.8): `dm:<person>` for a direct human message, `channel:<name>` for a channel. */
85
- export const RoomRef = z.string().regex(/^(?:dm:(?:[a-z][a-z0-9-]{0,31}|p_[a-z2-7]{26})|channel:[a-z][a-z0-9-]{0,31})$/, "dm:<person> or channel:<name>");
86
90
  export const SessionName = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/, "lowercase session name");
87
91
  export const MachineLabel = z.string().regex(/^[a-z0-9][a-z0-9-]{0,31}$/, "lowercase machine label");
88
92
  export const TeamName = z.string().min(1).max(64);
@@ -90,7 +94,7 @@ export const TeamName = z.string().min(1).max(64);
90
94
  export const AgentKind = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/, "lowercase agent kind");
91
95
  /**
92
96
  * The agent kind of the human `messaging` command line (spec §8.2, §8.3): never kept or expired on
93
- * disconnect, refused as a task executor, and the only sender of group addresses.
97
+ * disconnect and refused as a task executor. No sender can use group addresses.
94
98
  */
95
99
  export const CLI_AGENT = "cli";
96
100
  /** Milliseconds since the Unix epoch. */
@@ -134,6 +138,8 @@ export const LIMITS = {
134
138
  plaintextBytes: 262_144,
135
139
  /** Max characters in a single text part. */
136
140
  textPartChars: 65_536,
141
+ /** Existing model-visible per-part cap, shared with fenced conversation events. */
142
+ toolTextChars: 32_768,
137
143
  /** Max parts in one message. */
138
144
  partsPerMessage: 16,
139
145
  /** Default and max envelope TTL, and the max invite lifetime (`expires − ts`). */
@@ -282,12 +288,32 @@ export const LIMITS = {
282
288
  rekeyScheduleMs: 30 * 24 * 60 * 60 * 1000,
283
289
  /** Max chain keys in one sealed key share, and in one `keys` sync of the `self` conversation. */
284
290
  keyShares: 512,
285
- /** Max recipient devices one `keys` event seals to; a larger share is split over several events. */
286
- keySharesPerEvent: 500,
287
291
  /** Max agents one event's `wake` hint names. */
288
292
  wakeHints: 5,
289
293
  /** Max context rules a person holds (spec §16.8). */
290
294
  contextRules: 500,
295
+ /** Relay (spec §7, "Conversation frames"): appends one device makes to one conversation, a burst of this many... */
296
+ convAppendBurst: 20,
297
+ /** ...refilled at this many a minute. */
298
+ convAppendsPerMinute: 60,
299
+ /** Appends to one conversation from all its devices, a minute (burst and refill). */
300
+ convAppendsPerConvPerMinute: 600,
301
+ /** `rekey` ops with a cause the log cannot prove (`device`, `schedule`) per device per conversation, an hour. */
302
+ convRekeysPerHour: 4,
303
+ /** Conversations one device takes part in, through its person (create, add and join refuse past it). */
304
+ convsPerDevice: 5000,
305
+ /** Conversations one device creates, an hour (burst and refill). */
306
+ convCreatesPerHour: 60,
307
+ /** Key-share events in one `conv.share` frame. */
308
+ convSharesPerFrame: 100,
309
+ /** Key shares one sender device holds at the relay for one recipient device in one conversation. */
310
+ convSharesPerPair: 256,
311
+ /** Key shares one device sends to one conversation, a burst of this many, refilled per minute. */
312
+ convSharesPerMinute: 10_000,
313
+ /** Entries (and shares) in one `conv.entries` reply. */
314
+ convFetchEntries: 500,
315
+ /** Conversations named in one `conv.head`, and heads in one `conv.heads` reply. */
316
+ convHeads: 500,
291
317
  };
292
318
  const LOOPBACK_HOST = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])$/;
293
319
  /**
@@ -6,14 +6,15 @@
6
6
  import { z } from "../../vendor/zod/index.js";
7
7
  import { Base64Url, LIMITS, MachineId, Millis, PersonId, PROTOCOL, Ulid } from "./common.js";
8
8
  import { RealmId } from "./realm.js";
9
+ import { ConvId } from "./conversation.js";
9
10
  /** H2H lines from the subject: `muted` delivers with no notification and no unread mark; `blocked` refuses every class from them. */
10
11
  export const ChatLevel = z.enum(["open", "muted", "blocked"]);
11
12
  /** Lines from the subject's agents to the person (A2H). */
12
13
  export const AgentMailLevel = z.enum(["open", "blocked"]);
13
14
  /** Mail and tasks from the subject or their agents to the person's agents: `request` holds each one for the person to answer. */
14
15
  export const AskLevel = z.enum(["open", "request", "no"]);
15
- /** Whom a grant is about: one person, or every member of one channel (by its id). */
16
- export const GrantSubject = z.union([z.strictObject({ person: PersonId }), z.strictObject({ channel: Ulid })]);
16
+ /** Whom a grant is about: one person, or the verified participants of one conversation. */
17
+ export const GrantSubject = z.union([z.strictObject({ person: PersonId }), z.strictObject({ conv: ConvId })]);
17
18
  /**
18
19
  * A grant, signed by `by`, a device of `person`: `sig = Ed25519(by, "messaging/1/grant\n" || JCS(grant without sig))`.
19
20
  * Every level is optional: an absent level is the default for the relation (spec §6.9, "Effective policy"), and a
@@ -54,8 +55,8 @@ export const GrantSync = z.strictObject({ grants: z.array(z.record(z.string(), z
54
55
  /** The rules of a grant that its schema alone cannot say. */
55
56
  export function grantProblem(grant) {
56
57
  const own = "person" in grant.subject && grant.subject.person === grant.person;
57
- if ("channel" in grant.subject && (grant.chat !== undefined || grant.trustedUntil !== undefined || grant.discoverable !== undefined)) {
58
- return "a channel grant sets only ask and agentMail";
58
+ if ("conv" in grant.subject && (grant.chat !== undefined || grant.trustedUntil !== undefined || grant.discoverable !== undefined)) {
59
+ return "a conversation grant sets only ask and agentMail";
59
60
  }
60
61
  if (own && (grant.chat !== undefined || grant.agentMail !== undefined || grant.ask !== undefined || grant.trustedUntil !== undefined)) {
61
62
  return "a grant about its own person sets only discoverable";
@@ -0,0 +1,22 @@
1
+ /** Portable same-realm contact requests (spec §16.11). All proofs are strict and domain-separated. */
2
+ import { z } from "../../vendor/zod/index.js";
3
+ import { Hash, Key32, LIMITS, MachineId, Millis, PersonId, PROTOCOL, Ulid } from "./common.js";
4
+ import { RealmId } from "./realm.js";
5
+ const Dm = z.string().regex(/^d_[a-z2-7]{26}$/);
6
+ const Sig = z.string().regex(/^[A-Za-z0-9_-]{86}$/);
7
+ const Req = z.string().min(1).max(64);
8
+ const route = { v: z.literal(PROTOCOL), realm: RealmId, cid: Dm, from: PersonId, to: PersonId };
9
+ export const RequestDescriptor = z.strictObject({ ...route, id: Ulid, issuedAt: Millis, expiresAt: Millis, commitment: Hash, by: MachineId, sig: Sig });
10
+ export const RequestOpening = z.strictObject({ salt: Key32, text: z.string().min(1).max(LIMITS.requestChars) });
11
+ export const PortableRequest = z.strictObject({ descriptor: RequestDescriptor, opening: RequestOpening });
12
+ /** A block is conveyed as decline. The first valid terminal answer is sequenced in the recipient mailbox. */
13
+ export const RequestDecision = z.strictObject({ ...route, id: Ulid, descriptor: Hash, answer: z.enum(["accept", "decline"]), at: Millis, by: MachineId, sig: Sig });
14
+ export const RequestActivation = z.strictObject({ descriptor: RequestDescriptor, decision: RequestDecision });
15
+ /** Existing contacts authorize the exact human DM on both sides, under their current local grants. */
16
+ export const ContactAuthorization = z.strictObject({ ...route, at: Millis, by: MachineId, sig: Sig });
17
+ export const ActivationProof = z.union([RequestActivation, z.strictObject({ contacts: z.tuple([ContactAuthorization, ContactAuthorization]) })]);
18
+ export const RequestImport = z.strictObject({ t: z.literal("request.import"), ...RequestActivation.shape, opening: RequestOpening });
19
+ /** No opening or request text is sent to the relay. */
20
+ export const RequestSettleFrame = z.strictObject({ t: z.literal("request.settle"), req: Req, ...RequestActivation.shape });
21
+ export const RequestStateFrame = z.strictObject({ t: z.literal("request.state"), req: Req, descriptor: RequestDescriptor });
22
+ export const RequestSettledFrame = z.strictObject({ t: z.literal("request.settled"), req: Req, id: Ulid, decision: RequestDecision.optional() });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Conversation frames (spec §7, "Conversation frames"; spec §16): the frames a relay speaks after `hello` to carry
3
+ * conversation logs, in a roster realm and in a directory realm alike. They follow the frame rules of §7: one JSON
4
+ * object per text frame, discriminated by `t`; requests carry `req`, replies echo it, and a refusal is the relay
5
+ * binding's `nack {id: req, code, message}` (with `retryAfterMs` for `rate_limited`). Every frame is strict.
6
+ *
7
+ * conv.append {req, event} → conv.ok {req, cid, seq, hash} an op or a msg, sequenced
8
+ * conv.share {req, events} → conv.shared {req, cid, count} key shares, one recipient each
9
+ * conv.fetch {req, cid, after, limit?, sharesAfter?} → conv.entries {req, cid, head, entries, shares, more}
10
+ * conv.head {req, cids? | after?} → conv.heads {req, heads, more}
11
+ * conv.caps {req, persons} → conv.capabilities {req, outdated} who cannot read conversations yet
12
+ * relay → conv.push {cid, entry} | {cid, share} a new entry to the members' online devices; a share to its recipient
13
+ *
14
+ * The relay sequences and filters; members verify everything (spec §16.4, §16.7). It never signs an entry.
15
+ */
16
+ import { z } from "../../vendor/zod/index.js";
17
+ import { LIMITS, PersonId } from "./common.js";
18
+ import { ConvEvent, ConvHead, ConvId, ConvKeysEvent, ConvLogEntry, ConvServedEntry, ConvShareEntry } from "./conversation.js";
19
+ const Req = z.string().min(1).max(64);
20
+ const Count = z.number().int().min(0);
21
+ // ---- client → relay ----
22
+ /** Appends an op or a msg to its conversation (`event.cid`). The relay answers `conv.ok` once the entry is stored. */
23
+ export const ConvAppendFrame = z.strictObject({ t: z.literal("conv.append"), req: Req, event: ConvEvent });
24
+ /**
25
+ * Stores key shares (spec §16.6): each event to one recipient device, all of one conversation and by the sending
26
+ * device. All or none are stored; the relay answers `conv.shared` once they are.
27
+ */
28
+ export const ConvShareFrame = z.strictObject({
29
+ t: z.literal("conv.share"),
30
+ req: Req,
31
+ events: z.array(ConvKeysEvent).min(1).max(LIMITS.convSharesPerFrame),
32
+ });
33
+ /**
34
+ * The entries of `cid` after `after`, in seq order, at most `limit` (and `LIMITS.convFetchEntries`) and as many as
35
+ * fit in a frame. `sharesAfter`: also the shares to this device after that `n`; absent, no shares.
36
+ */
37
+ export const ConvFetchFrame = z.strictObject({
38
+ t: z.literal("conv.fetch"),
39
+ req: Req,
40
+ cid: ConvId,
41
+ after: Count,
42
+ limit: z.number().int().min(1).max(LIMITS.convFetchEntries).optional(),
43
+ sharesAfter: Count.optional(),
44
+ });
45
+ /**
46
+ * The heads of the named conversations, or, without `cids`, of every conversation this device's person takes part in,
47
+ * by cid after `after`, at most `LIMITS.convHeads`.
48
+ */
49
+ export const ConvHeadFrame = z
50
+ .strictObject({
51
+ t: z.literal("conv.head"),
52
+ req: Req,
53
+ cids: z.array(ConvId).min(1).max(LIMITS.convHeads).optional(),
54
+ after: ConvId.optional(),
55
+ })
56
+ .refine((f) => f.cids === undefined || f.after === undefined, { message: "cids or after, not both" });
57
+ /**
58
+ * Which of these persons cannot read conversations yet: every current device of theirs whose latest presence the
59
+ * relay holds names no `conversations` feature (a daemon before the cut-over). A person with no such presence is not
60
+ * named. A hint from the relay: it can only make a sender refuse, never admit anything.
61
+ */
62
+ export const ConvCapsFrame = z.strictObject({ t: z.literal("conv.caps"), req: Req, persons: z.array(PersonId).min(1).max(LIMITS.convOpParticipants) });
63
+ // ---- relay → client ----
64
+ /** `conv.append` stored: the entry's seq and h(seq), which the author recomputes. */
65
+ export const ConvOkFrame = z.strictObject({ t: z.literal("conv.ok"), req: Req, cid: ConvId, seq: z.number().int().min(1), hash: ConvHead.shape.hash });
66
+ /** `conv.share` stored: how many shares. */
67
+ export const ConvSharedFrame = z.strictObject({ t: z.literal("conv.shared"), req: Req, cid: ConvId, count: z.number().int().min(1).max(LIMITS.convSharesPerFrame) });
68
+ /**
69
+ * `conv.fetch`'s answer: the relay's head, the entries (an expired `msg` as its digest, `ConvExpiredEntry`) and this
70
+ * device's shares. `more`: ask again after the last entry (and the last share).
71
+ */
72
+ export const ConvEntriesFrame = z.strictObject({
73
+ t: z.literal("conv.entries"),
74
+ req: Req,
75
+ cid: ConvId,
76
+ head: ConvHead,
77
+ entries: z.array(ConvServedEntry).max(LIMITS.convFetchEntries),
78
+ shares: z.array(ConvShareEntry).max(LIMITS.convFetchEntries),
79
+ more: z.boolean(),
80
+ });
81
+ /** One conversation's position at the relay: its head, its op head, and the last share `n` for this device (0: none). */
82
+ export const ConvHeadView = z.strictObject({ cid: ConvId, head: ConvHead, opHead: ConvHead, shares: Count });
83
+ /** `conv.head`'s answer. A named conversation this device's person is not in is left out, as one that does not exist. */
84
+ export const ConvHeadsFrame = z.strictObject({ t: z.literal("conv.heads"), req: Req, heads: z.array(ConvHeadView).max(LIMITS.convHeads), more: z.boolean() });
85
+ /** `conv.caps`'s answer: the named persons that cannot read conversations yet. */
86
+ export const ConvCapabilitiesFrame = z.strictObject({ t: z.literal("conv.capabilities"), req: Req, outdated: z.array(PersonId).max(LIMITS.convOpParticipants) });
87
+ /** A new entry, to the online devices of the conversation's participant persons; or a new share, to its recipient. */
88
+ export const ConvPushFrame = z.union([
89
+ z.strictObject({ t: z.literal("conv.push"), cid: ConvId, entry: ConvLogEntry }),
90
+ z.strictObject({ t: z.literal("conv.push"), cid: ConvId, share: ConvShareEntry }),
91
+ ]);
92
+ /** The conversation frames' types, for the relay's dispatch and `/metrics`. */
93
+ export const CONV_FRAMES = ["conv.append", "conv.share", "conv.fetch", "conv.head", "conv.caps"];
94
+ export const CONV_REPLIES = ["conv.ok", "conv.shared", "conv.entries", "conv.heads", "conv.capabilities", "conv.push"];