@joe-broadhead/messaging 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/dist/bin/messaging-mcp.js +202 -31
  3. package/dist/bin/messaging-relay.js +291 -43
  4. package/dist/bin/messaging.js +290 -32
  5. package/dist/bin/messagingd.js +1158 -138
  6. package/dist/lib/modules/core/agent/present.js +5 -0
  7. package/dist/lib/modules/core/agent-tools.js +12 -0
  8. package/dist/lib/modules/core/consent.js +54 -14
  9. package/dist/lib/modules/core/contact-card.js +3 -3
  10. package/dist/lib/modules/core/index.js +1 -1
  11. package/dist/lib/modules/core/jelly-host.js +3 -0
  12. package/dist/lib/modules/core/schema/common.js +2 -0
  13. package/dist/lib/modules/core/schema/consent.js +20 -3
  14. package/dist/lib/modules/core/schema/conv-frames.js +10 -3
  15. package/dist/lib/modules/core/schema/directory-frames.js +8 -3
  16. package/dist/lib/modules/core/schema/errors.js +3 -2
  17. package/dist/lib/modules/core/schema/ipc/consent.js +85 -10
  18. package/dist/lib/modules/core/schema/ipc/conversation-views.js +8 -1
  19. package/dist/lib/modules/core/schema/ipc/conversations.js +5 -1
  20. package/dist/lib/modules/core/schema/ipc/device.js +7 -0
  21. package/dist/lib/modules/core/schema/ipc/jelly.js +14 -7
  22. package/dist/lib/modules/core/schema/ipc/shapes.js +60 -1
  23. package/dist/lib/modules/core/schema/ipc.js +51 -3
  24. package/dist/lib/modules/core/version.js +1 -1
  25. package/dist/spec/messaging-v1.md +321 -49
  26. package/dist/types/core/agent/present.d.ts +1 -1
  27. package/dist/types/core/agent-tools.d.ts +32 -0
  28. package/dist/types/core/consent.d.ts +30 -7
  29. package/dist/types/core/contact-card.d.ts +3 -3
  30. package/dist/types/core/conversation-agent-tools.d.ts +21 -0
  31. package/dist/types/core/index.d.ts +1 -1
  32. package/dist/types/core/schema/common.d.ts +2 -0
  33. package/dist/types/core/schema/consent.d.ts +23 -2
  34. package/dist/types/core/schema/conv-frames.d.ts +6 -2
  35. package/dist/types/core/schema/directory-frames.d.ts +8 -3
  36. package/dist/types/core/schema/envelope.d.ts +2 -0
  37. package/dist/types/core/schema/errors.d.ts +2 -0
  38. package/dist/types/core/schema/ipc/consent.d.ts +377 -15
  39. package/dist/types/core/schema/ipc/conversation-views.d.ts +176 -0
  40. package/dist/types/core/schema/ipc/conversations.d.ts +368 -0
  41. package/dist/types/core/schema/ipc/device.d.ts +6 -0
  42. package/dist/types/core/schema/ipc/jelly.d.ts +14 -6
  43. package/dist/types/core/schema/ipc/shapes.d.ts +198 -0
  44. package/dist/types/core/schema/ipc/surfaces.d.ts +1 -0
  45. package/dist/types/core/schema/ipc/tasks.d.ts +2 -0
  46. package/dist/types/core/schema/ipc.d.ts +826 -3
  47. package/dist/types/core/schema/relay.d.ts +4 -0
  48. package/dist/types/core/version.d.ts +1 -1
  49. package/dist/types/ipc/server.d.ts +6 -0
  50. package/package.json +1 -1
@@ -2,6 +2,7 @@ import { taskStateName } from "../agent-tools.js";
2
2
  import { MessagingError } from "../schema/errors.js";
3
3
  import { createFence, PEER_FENCE_TAG, provenanceOf } from "../peer-text.js";
4
4
  import { LIMITS } from "../schema/common.js";
5
+ import { TASK_REFUSAL_CHARS, TASK_SIGN_IN_TOOL_CHARS } from "../schema/ipc.js";
5
6
  import { chainPath, EXPECT_TEXT, isSafeId, messageAttributes } from "./text.js";
6
7
  /** Per-part cap in tool outputs; the protocol caps a text part at 65,536 chars. */
7
8
  const PART_CHARS = LIMITS.toolTextChars;
@@ -171,6 +172,10 @@ export function presentTask(framer, record, consent) {
171
172
  state: taskStateName(record.task.status.state),
172
173
  ...(title !== undefined ? { title: shown(framer, titleAuthor, peerAttributes(record))(title, TITLE_CHARS), titleAuthor } : {}),
173
174
  ...(status !== undefined ? { status: shown(framer, statusAuthor, peerAttributes(record))(status, PART_CHARS), statusAuthor } : {}),
175
+ ...(record.signIn !== undefined
176
+ ? { signIn: { ...(record.signIn.tool !== undefined ? { tool: shown(framer, statusAuthor, peerAttributes(record))(record.signIn.tool, TASK_SIGN_IN_TOOL_CHARS) } : {}), by: record.signIn.by } }
177
+ : {}),
178
+ ...(record.refusal !== undefined ? { refusal: { reason: shown(framer, statusAuthor, peerAttributes(record))(record.refusal.reason, TASK_REFUSAL_CHARS) } } : {}),
174
179
  updatedAt: isoOf(record.updatedAt),
175
180
  ...(consent !== undefined && record.role === "executor" ? { consent } : {}),
176
181
  ...(record.chain !== undefined ? { chain: presentChain(framer, record.chain) } : {}),
@@ -122,6 +122,7 @@ export const TOOL_REMEDIES = {
122
122
  login_denied: "tell your person: the sign-in at Jelly ID was denied.",
123
123
  log_conflict: "try again: their devices changed at the same time.",
124
124
  conv_conflict: "try again: the conversation changed at the same time.",
125
+ conflict: "tell your person: their settings changed at the same time; they choose again.",
125
126
  stale_epoch: "try again: the conversation's keys changed at the same time.",
126
127
  device_limit: "ask your person to remove one of their devices first.",
127
128
  admission_required: "ask your person to open Messaging in Jelly once: this device is not bound to their Jelly account yet.",
@@ -286,6 +287,17 @@ const Task = z.object({
286
287
  titleAuthor: Author.optional().describe("With title: who wrote the request; ours when you asked (mine)"),
287
288
  status: authored("The latest status message").optional(),
288
289
  statusAuthor: Author.optional().describe("With status: who wrote it; ours when you run the task (asked)"),
290
+ signIn: z
291
+ .object({
292
+ tool: authored("The tool the executor named").optional(),
293
+ by: z.enum(["you", "them"]).describe("Whose sign-in it is: you (your person, for an agent of theirs) or them (the other person); your Messaging daemon decides, never the peer"),
294
+ })
295
+ .optional()
296
+ .describe("auth_required only: the task waits on a sign-in; the tool was written by the executor, as statusAuthor says"),
297
+ refusal: z
298
+ .object({ reason: authored("The executor's reason") })
299
+ .optional()
300
+ .describe("rejected only: why the executor declined, in its words, as statusAuthor says"),
289
301
  createdAt: iso,
290
302
  updatedAt: iso,
291
303
  consent: ToolConsentSchema.optional(),
@@ -63,7 +63,19 @@ export function verifyConsentGrant(raw, ctx) {
63
63
  }
64
64
  /** The key one grant is stored under: its subject. One grant per subject. */
65
65
  export function grantSubjectKey(subject) {
66
- return "person" in subject ? `person:${subject.person}` : `conv:${subject.conv}`;
66
+ return "person" in subject ? `person:${subject.person}` : "conv" in subject ? `conv:${subject.conv}` : `relation:${subject.relation}`;
67
+ }
68
+ /** The choices a person's two relation grants make; a grant absent (or not current) leaves its choice unset. */
69
+ export function reachChoices(outside, org) {
70
+ const message = outside === undefined ? undefined : outside.chat === "open" ? "open" : outside.chat === "blocked" ? "never" : "ask";
71
+ const ask = org?.ask === "open" ? "open" : org?.ask === "request" ? "ask" : org?.ask === "no" ? "never" : undefined;
72
+ return { ...(message ? { message } : {}), ...(ask ? { ask } : {}) };
73
+ }
74
+ /** The relation grant subject and levels that record one choice: the inverse of `reachChoices`. */
75
+ export function reachGrant(group, value) {
76
+ if (group === "message")
77
+ return { subject: { relation: "outside" }, levels: value === "open" ? { chat: "open" } : value === "never" ? { chat: "blocked" } : {} };
78
+ return { subject: { relation: "org" }, levels: { ask: value === "open" ? "open" : value === "never" ? "no" : "request" } };
67
79
  }
68
80
  /**
69
81
  * Of two copies of the grant for one subject, the one that counts (spec §6.9, "Merging"): the newer by `ts`, then
@@ -126,12 +138,28 @@ const DEFAULTS = {
126
138
  };
127
139
  const ASK_RANK = { no: 0, request: 1, open: 2 };
128
140
  /**
129
- * Spec §6.9, "Effective policy": each level is the person grant's when it sets one; otherwise, for `ask` and
130
- * `agentMail`, the most permissive among the grants of channels the sender is in; otherwise the relation's
131
- * default. An expired `trustedUntil` reads as absent. The caller passes only grants whose signer is current.
141
+ * The level a relation starts from, under the person's reach choices (spec §6.9, "Reach choices"): `message` sets a
142
+ * stranger's `chat` (Open `open`, Ask me first `request_required`, Never `blocked`) and a contact's (Never `blocked`,
143
+ * else `open`: a contact has already been let in); `ask` sets the org's `ask`. Unset, the relation's default.
132
144
  */
133
- export function effectivePolicy(relation, person, channels, now) {
145
+ function baseLevels(relation, reach) {
134
146
  const base = DEFAULTS[relation];
147
+ if (relation === "org")
148
+ return reach.ask === undefined ? base : { ...base, ask: reach.ask === "open" ? "open" : reach.ask === "never" ? "no" : "request" };
149
+ if (reach.message === "never")
150
+ return { ...base, chat: "blocked" };
151
+ if (relation === "stranger" && reach.message === "open")
152
+ return { ...base, chat: "open" };
153
+ return base;
154
+ }
155
+ /**
156
+ * Spec §6.9, "Effective policy": each level is the person grant's when it sets one; otherwise, for `ask` and
157
+ * `agentMail`, the most permissive among the grants of channels the sender is in; otherwise the relation's level
158
+ * under the person's reach choices (`baseLevels`). An expired `trustedUntil` reads as absent. The caller passes only
159
+ * grants whose signer is current.
160
+ */
161
+ export function effectivePolicy(relation, person, channels, now, reach = {}) {
162
+ const base = baseLevels(relation, reach);
135
163
  const channelAsk = channels.map((grant) => grant.ask).filter((level) => level !== undefined);
136
164
  const channelMail = channels.map((grant) => grant.agentMail).filter((level) => level !== undefined);
137
165
  const ask = person?.ask ?? (channelAsk.length > 0 ? channelAsk.reduce((a, b) => (ASK_RANK[b] > ASK_RANK[a] ? b : a)) : undefined);
@@ -149,27 +177,35 @@ export function effectivePolicy(relation, person, channels, now) {
149
177
  * Spec §6.9, "Enforcement": step 10 of the receiver order (§5), before the inbound policy of §8.3.
150
178
  * - The person's own traffic is delivered as `self`.
151
179
  * - Answers to the person's own traffic are delivered whatever the grants, so a change never strands a task.
152
- * - A `blocked` chat level refuses every other class from the sender.
153
- * - A `stranger` reaches the person only through a level a grant opened; otherwise `request_required`.
180
+ * - A `blocked` chat level from the person grant refuses every other class from the sender.
181
+ * - A `blocked` chat level from the reach choice Never refuses the sender's lines, message requests and channel
182
+ * changes, `refused`; agent mail and asks follow their own levels, so a grant that opened them still counts.
183
+ * - A `stranger` reaches the person only through a level a grant or a choice opened; otherwise `request_required`.
154
184
  * - Lines follow `chat` (person) or `agentMail` (agent); `muted` delivers silently.
155
185
  * - Asks follow `ask`: `open` delivers guarded, or trusted while the person grant trusts the sender and every person
156
186
  * in the delegation chain is the sender or the receiver; `request` holds; `no` refuses, `request_required` when
157
- * it is the default of a `contact` or a `stranger`.
158
- * - A message request from a `stranger` is held for the person to accept; from anyone else it is a DM line.
187
+ * it is the default of a `contact` or a `stranger` and the person has not chosen Never for messages.
188
+ * - A message request is held for the person while `chat` is `request_required` (a `stranger` under Ask me first);
189
+ * otherwise it is a DM line.
159
190
  */
160
191
  export function decide(args) {
161
192
  const { inbound } = args;
162
193
  if (args.relation === "self" || args.sender === args.self)
163
194
  return { action: "deliver", consent: "self" };
164
- const policy = effectivePolicy(args.relation, args.grant, args.channels, args.now);
195
+ const policy = effectivePolicy(args.relation, args.grant, args.channels, args.now, args.reach);
165
196
  const trusted = (chain) => policy.trustedUntil !== undefined && chain.every((person) => person === args.sender || person === args.self);
166
197
  if (inbound.kind === "answer")
167
198
  return { action: "deliver", consent: trusted([args.sender]) ? "trusted" : "guarded" };
168
- if (policy.chat === "blocked")
199
+ const blocked = policy.chat === "blocked";
200
+ if (blocked && policy.granted.chat)
169
201
  return { action: "refuse", code: "refused" };
170
202
  const stranger = args.relation === "stranger";
203
+ // A default refusal invites a message request, unless the person chose Never for messages.
204
+ const defaultCode = (outside) => (outside && !blocked ? "request_required" : "refused");
171
205
  switch (inbound.kind) {
172
206
  case "op":
207
+ if (blocked)
208
+ return { action: "refuse", code: "refused" };
173
209
  if (stranger)
174
210
  return { action: "refuse", code: "request_required" };
175
211
  return { action: "deliver", consent: "guarded" };
@@ -177,13 +213,17 @@ export function decide(args) {
177
213
  if (inbound.agent) {
178
214
  if (policy.agentMail === "open")
179
215
  return { action: "deliver", consent: "guarded" };
180
- return { action: "refuse", code: stranger && !policy.granted.agentMail ? "request_required" : "refused" };
216
+ return { action: "refuse", code: defaultCode(stranger && !policy.granted.agentMail) };
181
217
  }
218
+ if (blocked)
219
+ return { action: "refuse", code: "refused" };
182
220
  if (policy.chat === "request_required")
183
221
  return { action: "refuse", code: "request_required" };
184
222
  return policy.chat === "muted" ? { action: "deliver", consent: "guarded", silent: true } : { action: "deliver", consent: "guarded" };
185
223
  case "request":
186
- if (stranger)
224
+ if (blocked)
225
+ return { action: "refuse", code: "refused" };
226
+ if (policy.chat === "request_required")
187
227
  return { action: "hold" };
188
228
  return policy.chat === "muted" ? { action: "deliver", consent: "guarded", silent: true } : { action: "deliver", consent: "guarded" };
189
229
  case "ask":
@@ -191,7 +231,7 @@ export function decide(args) {
191
231
  return { action: "deliver", consent: trusted(inbound.chain) ? "trusted" : "guarded" };
192
232
  if (policy.ask === "request")
193
233
  return { action: "hold" };
194
- return { action: "refuse", code: !policy.granted.ask && (stranger || args.relation === "contact") ? "request_required" : "refused" };
234
+ return { action: "refuse", code: defaultCode(!policy.granted.ask && (stranger || args.relation === "contact")) };
195
235
  }
196
236
  }
197
237
  function defined(value) {
@@ -61,10 +61,10 @@ export function verifyContactCard(raw, ctx) {
61
61
  }
62
62
  /**
63
63
  * What a card says for a sender of `relation` with the person grant `grant` (spec §6.9, "Contact cards"): the
64
- * person grant over the relation's defaults. Channel grants are not on a card: the sender knows its own channels.
64
+ * person grant over the relation's level under the person's reach choices. Channel grants are not on a card: the sender knows its own channels.
65
65
  */
66
- export function cardCan(relation, grant, now) {
67
- const policy = effectivePolicy(relation, grant, [], now);
66
+ export function cardCan(relation, grant, now, reach = {}) {
67
+ const policy = effectivePolicy(relation, grant, [], now, reach);
68
68
  return {
69
69
  chat: policy.chat === "open" || policy.chat === "muted",
70
70
  agentMail: policy.agentMail === "open",
@@ -35,7 +35,7 @@ export { MerkleLog, merkleLeafHash, verifyConsistency, verifyInclusion } from ".
35
35
  export { MAP_DEPTH, SparseMerkleTree, TOMBSTONE, emptyMapHash, mapKey, mapRootFromProof, mapValue, verifyMapProof } from "./directory/smt.js";
36
36
  export { parseAddress, formatAddress } from "./address.js";
37
37
  export { decodeAdmitCard, decodeTeamCard, encodeAdmitCard, encodeTeamCard, } from "./cards.js";
38
- export { decide, effectivePolicy, grantSubjectKey, levelsOf, preferGrant, resignGrant, restrictingLevels, signConsentGrant, verifyConsentGrant, wideningLevels, } from "./consent.js";
38
+ export { decide, effectivePolicy, grantSubjectKey, levelsOf, preferGrant, reachChoices, reachGrant, resignGrant, restrictingLevels, signConsentGrant, verifyConsentGrant, wideningLevels, } from "./consent.js";
39
39
  export { cardCan, signContactCard, verifyContactCard } from "./contact-card.js";
40
40
  export { createGrant, verifyChain } from "./delegation.js";
41
41
  export { canTransition, isTerminal, isInterrupted } from "./task.js";
@@ -37,8 +37,11 @@ const VIEW_METHODS = [
37
37
  "people.search",
38
38
  "people.get",
39
39
  "grant.list",
40
+ "reach.get",
40
41
  "request.list",
41
42
  "sessions.list",
43
+ "tasks.list",
44
+ "tasks.get",
42
45
  "notice.subscribe",
43
46
  "notice.list",
44
47
  "device.list",
@@ -66,6 +66,8 @@ export const URI = {
66
66
  binding: "urn:messaging:binding:relay:v1",
67
67
  provenance: "urn:messaging:ext:provenance:v1",
68
68
  presence: "urn:messaging:ext:presence:v1",
69
+ /** An `AUTH_REQUIRED` status message's `metadata[URI.signIn]`: `{tool}`, the tool whose sign-in the task waits on (spec §6.2). */
70
+ signIn: "urn:messaging:ext:sign-in:v1",
69
71
  };
70
72
  export const Base64Url = z.string().regex(/^[A-Za-z0-9_-]*$/, "base64url without padding");
71
73
  /** Any 32-byte value in base64url, e.g. a raw public key. */
@@ -13,13 +13,20 @@ export const ChatLevel = z.enum(["open", "muted", "blocked"]);
13
13
  export const AgentMailLevel = z.enum(["open", "blocked"]);
14
14
  /** Mail and tasks from the subject or their agents to the person's agents: `request` holds each one for the person to answer. */
15
15
  export const AskLevel = z.enum(["open", "request", "no"]);
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 })]);
16
+ /**
17
+ * The relations a reach choice is about (spec §6.9, "Reach choices"): `outside`, the people of other orgs (`contact`
18
+ * and `stranger`), whose `chat` the person's "Who can message you" sets; `org`, the person's teammates, whose `ask`
19
+ * their "How agents may ask yours" sets.
20
+ */
21
+ export const GrantRelation = z.enum(["outside", "org"]);
22
+ /** Whom a grant is about: one person, the verified participants of one conversation, or one relation (a reach choice). */
23
+ export const GrantSubject = z.union([z.strictObject({ person: PersonId }), z.strictObject({ conv: ConvId }), z.strictObject({ relation: GrantRelation })]);
18
24
  /**
19
25
  * A grant, signed by `by`, a device of `person`: `sig = Ed25519(by, "messaging/1/grant\n" || JCS(grant without sig))`.
20
26
  * Every level is optional: an absent level is the default for the relation (spec §6.9, "Effective policy"), and a
21
27
  * grant with none is a clear. `trustedUntil` needs `ask: "open"` and is at most `ts + LIMITS.trustMaxMs`. A channel
22
- * grant sets only `ask` and `agentMail`. A grant about the person themself sets only `discoverable`.
28
+ * grant sets only `ask` and `agentMail`. A grant about the person themself sets only `discoverable`. A relation
29
+ * grant (a reach choice) sets only `chat` (`open` or `blocked`) for `outside`, and only `ask` for `org`.
23
30
  * `resignedAt` marks a grant re-signed by another device of the person (spec §6.9, "Signers"); it keeps `id` and
24
31
  * `ts`.
25
32
  */
@@ -58,6 +65,16 @@ export function grantProblem(grant) {
58
65
  if ("conv" in grant.subject && (grant.chat !== undefined || grant.trustedUntil !== undefined || grant.discoverable !== undefined)) {
59
66
  return "a conversation grant sets only ask and agentMail";
60
67
  }
68
+ if ("relation" in grant.subject) {
69
+ if (grant.agentMail !== undefined || grant.trustedUntil !== undefined || grant.discoverable !== undefined)
70
+ return "a relation grant sets only chat or ask";
71
+ if (grant.subject.relation === "outside" && grant.ask !== undefined)
72
+ return "the outside relation grant sets only chat";
73
+ if (grant.subject.relation === "outside" && grant.chat === "muted")
74
+ return "the outside relation grant sets chat open or blocked";
75
+ if (grant.subject.relation === "org" && grant.chat !== undefined)
76
+ return "the org relation grant sets only ask";
77
+ }
61
78
  if (own && (grant.chat !== undefined || grant.agentMail !== undefined || grant.ask !== undefined || grant.trustedUntil !== undefined)) {
62
79
  return "a grant about its own person sets only discoverable";
63
80
  }
@@ -8,7 +8,7 @@
8
8
  * conv.share {req, events} → conv.shared {req, cid, count} key shares, one recipient each
9
9
  * conv.fetch {req, cid, after, limit?, sharesAfter?} → conv.entries {req, cid, head, entries, shares, more}
10
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
11
+ * conv.caps {req, persons} → conv.capabilities {req, outdated, unknown?} who cannot read conversations yet
12
12
  * relay → conv.push {cid, entry} | {cid, share} a new entry to the members' online devices; a share to its recipient
13
13
  *
14
14
  * The relay sequences and filters; members verify everything (spec §16.4, §16.7). It never signs an entry.
@@ -82,8 +82,15 @@ export const ConvEntriesFrame = z.strictObject({
82
82
  export const ConvHeadView = z.strictObject({ cid: ConvId, head: ConvHead, opHead: ConvHead, shares: Count });
83
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
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) });
85
+ /**
86
+ * `conv.caps`'s answer: the named persons that cannot read conversations yet, and, only when there are any, the
87
+ * persons it says nothing about (`unknown`: unrelated persons past the asker's daily budget).
88
+ */
89
+ export const ConvCapabilitiesFrame = z.strictObject({
90
+ t: z.literal("conv.capabilities"), req: Req,
91
+ outdated: z.array(PersonId).max(LIMITS.convOpParticipants),
92
+ unknown: z.array(PersonId).min(1).max(LIMITS.convOpParticipants).optional(),
93
+ });
87
94
  /** A new entry, to the online devices of the conversation's participant persons; or a new share, to its recipient. */
88
95
  export const ConvPushFrame = z.union([
89
96
  z.strictObject({ t: z.literal("conv.push"), cid: ConvId, entry: ConvLogEntry }),
@@ -23,7 +23,7 @@
23
23
  * dir.watch {req, persons} → dir.ok {req}; then dir.changed {persons, sth} pushes
24
24
  * presence.watch {req, persons} → dir.ok {req}
25
25
  * dir.erase {req, entries, token} → dir.ok {req}
26
- * wake.arm {req, url, ticket, agents, awaiting} → dir.ok {req} a Jelly host's device, before it sleeps (§7, "Wake tickets")
26
+ * wake.arm {req, url, ticket, agents, awaiting, dms?} → dir.ok {req} a Jelly host's device, before it sleeps (§7, "Wake tickets")
27
27
  * wake.disarm {req} → dir.ok {req}
28
28
  * relay → dir.unbound {entry, sth} this device's own unbind, just before close 4003
29
29
  *
@@ -36,6 +36,7 @@ import { Base64Url, Hash, HostedPersonId, LIMITS, Millis, RELAY_PROTOCOL, Sessio
36
36
  import { EpochProof } from "./directory.js";
37
37
  import { RealmId } from "./realm.js";
38
38
  import { DeviceId, KeyRotation, KeysStatement, LogEntry, PersonHead, PersonView, STH } from "./directory.js";
39
+ import { ConvId } from "./conversation.js";
39
40
  import { Jws } from "./jelly.js";
40
41
  const Req = z.string().min(1).max(64);
41
42
  const Size = z.number().int().positive();
@@ -150,6 +151,8 @@ export const DirEraseFrame = z.object({
150
151
  /** Most agents a wake ticket's filter names, and most awaited task requests (spec §7, "Wake tickets"). */
151
152
  export const WAKE_AGENTS = 64;
152
153
  export const WAKE_AWAITING = 500;
154
+ /** Most dm conversations a wake ticket's filter names (spec §7, "Wake tickets", "a peer's dm"). */
155
+ export const WAKE_DMS = 256;
153
156
  /** Most bytes of a wake ticket, and its longest life: Jelly's `jelly-wake+jwt` lives at most seven days. */
154
157
  export const WAKE_TICKET_BYTES = 4096;
155
158
  export const WAKE_TICKET_MAX_S = 7 * 24 * 60 * 60;
@@ -159,8 +162,9 @@ export const WakeUrl = z.string().max(2048).regex(/^https?:\/\/[^/?#@\s]+\/hosts
159
162
  * A Jelly host's device arms a wake ticket before the host sleeps (spec §7, "Wake tickets"). `url` is Jelly ID's wake
160
163
  * endpoint for this device's host; `ticket` the host's single-use `jelly-wake+jwt`, which the relay never verifies
161
164
  * (Jelly ID does) and reads only for its `exp`; `agents` the local sessions whose own person's mail may wake the host;
162
- * `awaiting` the request envelope ids of the tasks those agents wait on, whose updates (`replyTo`) may wake it. A new
163
- * arm replaces the device's previous one.
165
+ * `awaiting` the request envelope ids of the tasks those agents wait on, whose updates (`replyTo`) may wake it; `dms`
166
+ * the dm conversations whose other participant's events may wake it (absent: none), which the host's daemon chose. A
167
+ * new arm replaces the device's previous one.
164
168
  */
165
169
  export const WakeArmFrame = z.object({
166
170
  t: z.literal("wake.arm"),
@@ -169,6 +173,7 @@ export const WakeArmFrame = z.object({
169
173
  ticket: Jws.refine((ticket) => ticket.length <= WAKE_TICKET_BYTES, { message: `at most ${WAKE_TICKET_BYTES} bytes` }),
170
174
  agents: z.array(SessionName).max(WAKE_AGENTS),
171
175
  awaiting: z.array(Ulid).max(WAKE_AWAITING),
176
+ dms: z.array(ConvId).max(WAKE_DMS).optional(),
172
177
  });
173
178
  /** The relay forgets this device's wake ticket. */
174
179
  export const WakeDisarmFrame = z.object({ t: z.literal("wake.disarm"), req: Req });
@@ -30,6 +30,7 @@ export const ErrorCode = z.enum([
30
30
  "jelly_unavailable", // Jelly ID unreachable or an unusable response for keys or sign-in; never reported as invalid
31
31
  "log_conflict", // a device-log entry does not extend the log's head; resync and retry
32
32
  "conv_conflict", // a conversation event does not extend the log, or its op head is not the current one; re-read and retry
33
+ "conflict", // a person's setting changed since the caller read it (a stale revision, reach.set); nothing changed: read again
33
34
  "stale_epoch", // a conversation message is sealed under an epoch that is not the current one, or a rekey is due; re-seal and retry
34
35
  "device_limit", // the person already has LIMITS.devicesPerPerson current devices
35
36
  "admission_required", // dir.bind without a Jelly-vouched bind of the connecting device
@@ -39,8 +40,8 @@ export const ErrorCode = z.enum([
39
40
  "directory_unavailable", // the key directory could not be reached to verify a person's devices within the freshness bound
40
41
  "login_unsupported", // Jelly ID offers no device sign-in for this realm's resource (no v1.2 device sign-in in its metadata)
41
42
  "login_denied", // the person denied the sign-in at Jelly ID, or the account is disabled or being deleted
42
- "grant_required", // deployment "jelly": a human- or admin-tier call carried no action grant (spec §8.11)
43
- "grant_invalid", // deployment "jelly": the action grant did not verify, is for another call, expired or was spent
43
+ "grant_required", // deployment "jelly": a human- or admin-tier call carried no action grant (spec §8.11), or request.answer no action token (§8.2)
44
+ "grant_invalid", // the action grant (deployment "jelly") or an action token (§8.2) did not verify, is for another call, expired or was spent
44
45
  "ticket_required", // deployment "jelly": session.register carried no session ticket
45
46
  "ticket_invalid", // deployment "jelly": the session ticket did not verify, expired, or names another session or agent
46
47
  "daemon_unavailable", // client could not reach the local daemon socket
@@ -7,6 +7,7 @@ import { LIMITS, MachineId, MachineLabel, Millis, PersonId, SessionAddress, Sess
7
7
  import { ConvId, ParticipantId } from "../conversation.js";
8
8
  import { AgentMailLevel, AskLevel, CardCan, ChatLevel } from "../consent.js";
9
9
  import { Email } from "../directory-frames.js";
10
+ import { ActionToken } from "./shapes.js";
10
11
  /** A grant's subject: a person, or a verified conversation id. */
11
12
  export const GrantTarget = z.union([z.strictObject({ person: PersonId }), z.strictObject({ conv: ConvId })]);
12
13
  const Levels = {
@@ -43,6 +44,21 @@ export const GrantReview = z.object({
43
44
  * level it is a clear. `trust: true` trusts for `LIMITS.trustDefaultMs`; `trustedUntil` names the end instead.
44
45
  */
45
46
  export const GrantSetParams = z.strictObject({ subject: GrantTarget, ...Levels, trust: z.literal(true).optional() });
47
+ /**
48
+ * For an ask: `once` delivers it, guarded; `always` sets the sender's grant to `ask: "open"` (keeping its other
49
+ * levels), then delivers it, guarded. For a message request: `accept` sets the sender's grant to `chat: "open"`
50
+ * (keeping its other levels), files the request as their first line and sends them a card. For both: `decline`
51
+ * refuses it (`declined`); `block` sets the sender's grant to `chat: "blocked"` (keeping its other levels), then
52
+ * declines it. For a suggestion: `decline` dismisses it.
53
+ */
54
+ export const RequestAnswer = z.enum(["once", "always", "accept", "decline", "block"]);
55
+ /**
56
+ * A source-issued action on a held request (spec §8.2, "Action tokens"): `name` is the answer it gives, redeemed with
57
+ * `request.answer {id, answer: name, token}` on the connection that read it.
58
+ */
59
+ export const RequestAction = z.strictObject({ name: RequestAnswer, token: ActionToken });
60
+ /** One per answer the request's kind takes; only on the human tier, only while the request is pending. */
61
+ const Actions = { actions: z.array(RequestAction).max(4).optional() };
46
62
  /** A request this device holds for its person to answer (spec §6.9, "Holds"): mail or a task to one of their agents. */
47
63
  export const AskRequest = z.object({
48
64
  /** The envelope id. */
@@ -56,6 +72,7 @@ export const AskRequest = z.object({
56
72
  at: Millis,
57
73
  /** When the request expires, unanswered. */
58
74
  exp: Millis,
75
+ ...Actions,
59
76
  });
60
77
  /**
61
78
  * A message request this device holds for its person (spec §6.9, "Message requests"): a person of another org asks to
@@ -79,8 +96,9 @@ export const ContactRequestView = z.object({
79
96
  text: z.string().max(LIMITS.requestChars),
80
97
  at: Millis,
81
98
  exp: Millis,
99
+ ...Actions,
82
100
  });
83
- export const AgentInvitationRequest = z.strictObject({ kind: z.literal("agent-invitation"), id: Ulid, cid: ConvId, from: ParticipantId, to: ParticipantId, at: Millis });
101
+ export const AgentInvitationRequest = z.strictObject({ kind: z.literal("agent-invitation"), id: Ulid, cid: ConvId, from: ParticipantId, to: ParticipantId, at: Millis, ...Actions });
84
102
  /**
85
103
  * A contact one of this person's agents asks them to make (spec §6.9, "Message requests"): `send {to: "me", request:
86
104
  * {contact}}`. Only the person sends a message request, with `contact.request`; answering this one `decline`
@@ -93,17 +111,10 @@ export const ContactSuggestion = z.object({
93
111
  agent: SessionName,
94
112
  email: Email,
95
113
  at: Millis,
114
+ ...Actions,
96
115
  });
97
116
  /** What `request.list` lists: asks to the person's agents, message requests, and contacts their agents suggest. */
98
117
  export const HeldRequest = z.union([AskRequest, ContactRequestView, ContactSuggestion, AgentInvitationRequest]);
99
- /**
100
- * For an ask: `once` delivers it, guarded; `always` sets the sender's grant to `ask: "open"` (keeping its other
101
- * levels), then delivers it, guarded. For a message request: `accept` sets the sender's grant to `chat: "open"`
102
- * (keeping its other levels), files the request as their first line and sends them a card. For both: `decline`
103
- * refuses it (`declined`); `block` sets the sender's grant to `chat: "blocked"` (keeping its other levels), then
104
- * declines it. For a suggestion: `decline` dismisses it.
105
- */
106
- export const RequestAnswer = z.enum(["once", "always", "accept", "decline", "block"]);
107
118
  /** A message request's text: 1 to `LIMITS.requestChars` UTF-16 code units. */
108
119
  export const RequestText = z
109
120
  .string()
@@ -119,10 +130,74 @@ export const RequestText = z
119
130
  */
120
131
  export const PersonReach = z.object({ person: PersonId, can: CardCan, source: z.enum(["card", "default"]), at: Millis.optional(), devicesChangedAt: Millis.optional() });
121
132
  /** `request.answer`'s params, which `request.answer.preview` takes too (spec §8.2, "Previews"). */
122
- export const RequestAnswerParams = z.strictObject({ id: Ulid, answer: RequestAnswer, all: z.boolean().optional() });
133
+ /**
134
+ * `request.answer`'s params. `token` is the request's action for this `answer` (`RequestAction`); it answers that one
135
+ * request, so it never comes with `all`. Under a Jelly host it is required.
136
+ */
137
+ export const RequestAnswerParams = z
138
+ .strictObject({ id: Ulid, answer: RequestAnswer, all: z.boolean().optional(), token: ActionToken.optional() })
139
+ .refine((p) => p.token === undefined || p.all !== true, "an action token answers one request: drop all");
123
140
  /** `contact.request`'s params; its preview takes them with `text` optional (it reads only `email`). */
124
141
  export const ContactRequestParams = z.strictObject({ email: Email, text: RequestText, suggestion: Ulid.optional() });
142
+ /** A reach question (spec §6.9, "Reach choices"): `message` is "Who can message you", `ask` "How agents may ask yours". */
143
+ export const ReachGroupId = z.enum(["message", "ask"]);
144
+ /** An answer: Open, Ask me first (the default), Never. */
145
+ export const ReachValue = z.enum(["open", "ask", "never"]);
146
+ export const ReachOption = z.object({ value: ReachValue, label: z.string().max(40), description: z.string().max(300) });
147
+ /**
148
+ * One question with its options. `value` is the person's choice, or null while they have made none and the relation
149
+ * defaults apply. `lapsed` is a choice a removed device of the person made, which lapsed with it (§6.9, "Signers")
150
+ * and waits for the person to choose again.
151
+ */
152
+ export const ReachGroup = z.object({
153
+ id: ReachGroupId,
154
+ label: z.string().max(80),
155
+ options: z.array(ReachOption).length(3),
156
+ value: ReachValue.nullable(),
157
+ lapsed: ReachValue.optional(),
158
+ });
159
+ /** `reach.get`'s result, and `reach.set`'s: both questions, and a `revision` that changes whenever either choice does. */
160
+ export const ReachSnapshot = z.object({ revision: z.string().min(1).max(64), available: z.boolean(), groups: z.array(ReachGroup).length(2) });
161
+ /** `reach.set`'s params, which `reach.set.preview` takes too: the `revision` read, and only the declared answers that change. */
162
+ export const ReachSetParams = z.strictObject({
163
+ revision: z.string().min(1).max(64),
164
+ changes: z.strictObject({ message: ReachValue.optional(), ask: ReachValue.optional() }),
165
+ });
166
+ /** The fixed words of the two questions, in `reach.get`'s order. */
167
+ export const REACH_QUESTIONS = [
168
+ {
169
+ id: "message",
170
+ label: "Who can message you",
171
+ options: [
172
+ { value: "open", label: "Open", description: "Anyone outside your org may message you." },
173
+ { value: "ask", label: "Ask me first", description: "People outside your org send a message request first, and you accept or decline it. People you have accepted may message you." },
174
+ { value: "never", label: "Never", description: "No one outside your org may message you or send you a request. Your org, and people you have let message you, still can." },
175
+ ],
176
+ },
177
+ {
178
+ id: "ask",
179
+ label: "How agents may ask yours",
180
+ options: [
181
+ { value: "open", label: "Open", description: "Your teammates' agents may ask your agents without asking you. Their requests run guarded." },
182
+ { value: "ask", label: "Ask me first", description: "Each request from a teammate's agent to yours waits for your answer." },
183
+ { value: "never", label: "Never", description: "Your teammates' agents may not ask yours. Agents of people outside your org never may." },
184
+ ],
185
+ },
186
+ ];
125
187
  export const CONSENT_METHODS = {
188
+ /**
189
+ * The person's two reach choices (spec §6.9, "Reach choices"), each with its options and the current value (null:
190
+ * none made, the defaults apply), and a revision. Read-only.
191
+ */
192
+ "reach.get": { params: z.strictObject({}), result: ReachSnapshot },
193
+ /**
194
+ * Records the declared answers in `changes` as the person's relation grants, signed by this device and sent to
195
+ * their other devices, in one transaction; returns the new snapshot. `conflict` when `revision` is not the current
196
+ * one: nothing is changed, and the caller reads again.
197
+ */
198
+ "reach.set": { params: ReachSetParams, result: ReachSnapshot },
199
+ /** What `reach.set` with these params would do, in one fixed sentence; checks them the same way (`conflict` too); changes nothing. */
200
+ "reach.set.preview": { params: ReachSetParams, result: z.object({ sentence: z.string().max(500) }) },
126
201
  /** This person's grants, newest first, and the review list. With `subject`, only that subject's. */
127
202
  "grant.list": {
128
203
  params: z.strictObject({ subject: GrantTarget.optional() }),
@@ -6,6 +6,7 @@ import { z } from "../../../vendor/zod/index.js";
6
6
  import { LIMITS, Millis, SessionName, Ulid } from "../common.js";
7
7
  import { AgentContext, ConvId, ConvKind, ConvRole, HistoryPolicy, Mention, ParticipantId, RespondTo, Visibility } from "../conversation.js";
8
8
  import { TaskState } from "../a2a.js";
9
+ import { ActionToken, PersonTask } from "./shapes.js";
9
10
  const Count = z.number().int().nonnegative().safe();
10
11
  const Seq = z.number().int().nonnegative().safe();
11
12
  const Name = z.string().max(200);
@@ -33,7 +34,11 @@ export const FileView = z.object({
33
34
  blob: z.string().regex(/^b_[A-Za-z0-9_-]{43}$/),
34
35
  name: z.string().max(255), mime: z.string().max(100), bytes: z.number().int().positive().max(LIMITS.blobBytes),
35
36
  });
36
- export const TaskView = z.object({ id: Ulid, state: TaskState });
37
+ /**
38
+ * `cancel`: the source-issued action that stops the task (spec §8.2, "Action tokens"), redeemed with
39
+ * `task.cancel {taskId, token}`. Only on a task this person's agent asked that has not ended, and only on the human tier.
40
+ */
41
+ export const TaskView = z.object({ id: Ulid, state: TaskState, cancel: z.strictObject({ name: z.literal("cancel"), token: ActionToken }).optional() });
37
42
  export const EventView = z.object({
38
43
  cid: ConvId, id: Ulid, seq: z.number().int().positive().safe(), at: Millis,
39
44
  author: ParticipantView.pick({ p: true, kind: true, name: true }),
@@ -75,6 +80,8 @@ export const ConvStreamEvent = z.discriminatedUnion("type", [
75
80
  z.object({ type: z.literal("typing"), cid: ConvId, p: ParticipantId, thread: Ulid.optional(), until: Millis }),
76
81
  z.object({ type: z.literal("activity"), body: ActivityBody }),
77
82
  z.object({ type: z.literal("gap"), pos: Seq }),
83
+ /** Human tier, person-level `conv.sync` only (no session key): a task of this person's agents changed (spec §12.4). */
84
+ z.object({ type: z.literal("task"), pos: Seq, task: PersonTask }),
78
85
  ]).refine((entry) => entry.type !== "event.update" || (entry.cid === entry.event.cid && entry.id === entry.event.id), {
79
86
  message: "event.update names the same conversation and event as its projection",
80
87
  });
@@ -33,7 +33,11 @@ const OptionalSettings = { visibility: Visibility.optional(), history: HistoryPo
33
33
  export const WaitingOnThemView = z.object({
34
34
  id: Ulid, kind: z.enum(["message", "task", "contact"]), to: PersonId,
35
35
  name: z.string().max(200), at: Millis, cid: ConvId.optional(), agent: SessionName.optional(),
36
- state: z.enum(["held", "pending-delivery"]),
36
+ /**
37
+ * `resend`: a request sent before the cut-over and held unanswered; it must be sent again. `outdated`: the recipient
38
+ * accepted it on a client before conversations, which cannot open the dm; they update, then it is sent again (§8.3).
39
+ */
40
+ state: z.enum(["held", "pending-delivery", "resend", "outdated"]),
37
41
  });
38
42
  export const ConvListParams = z.strictObject({ key: Key, filter: z.strictObject({ unread: z.boolean().optional(), mentions: z.boolean().optional(), kind: ConvKind.optional(), archived: z.boolean().optional() }).optional(), ...Page });
39
43
  export const ConvListResult = ConvPage;
@@ -52,6 +52,8 @@ export const DeviceChangeView = z.object({
52
52
  approver: MachineLabel.optional(),
53
53
  at: Millis,
54
54
  });
55
+ /** A presence feature name, such as `tasks`, `conversations` or `reach`. */
56
+ export const PresenceFeature = z.string().regex(/^[a-z][a-z0-9-]{0,31}$/, "a feature name");
55
57
  /** One device of this device's person. */
56
58
  export const DeviceListEntry = z.object({
57
59
  device: MachineId,
@@ -72,6 +74,11 @@ export const DeviceListEntry = z.object({
72
74
  since: Millis.optional(),
73
75
  /** For a removed device of a directory realm: when, why and by which device. */
74
76
  unbound: z.object({ at: Millis, reason: UnbindReason, by: MachineId }).optional(),
77
+ /**
78
+ * The protocol features the device advertises (spec §8.2, "Devices"): this device's own, or the last verified presence
79
+ * of another device of the person. Absent when no presence of it has been seen, which is unknown, not none.
80
+ */
81
+ features: z.array(PresenceFeature).max(32).optional(),
75
82
  });
76
83
  /** A link request's text (`messaging-link.…`, spec §15.3): what a new device shows and its person's device reads. */
77
84
  const LinkText = z.string().min(1).max(2048);