@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.
- package/CHANGELOG.md +150 -0
- package/dist/bin/messaging-mcp.js +202 -31
- package/dist/bin/messaging-relay.js +291 -43
- package/dist/bin/messaging.js +290 -32
- package/dist/bin/messagingd.js +1158 -138
- package/dist/lib/modules/core/agent/present.js +5 -0
- package/dist/lib/modules/core/agent-tools.js +12 -0
- package/dist/lib/modules/core/consent.js +54 -14
- package/dist/lib/modules/core/contact-card.js +3 -3
- package/dist/lib/modules/core/index.js +1 -1
- package/dist/lib/modules/core/jelly-host.js +3 -0
- package/dist/lib/modules/core/schema/common.js +2 -0
- package/dist/lib/modules/core/schema/consent.js +20 -3
- package/dist/lib/modules/core/schema/conv-frames.js +10 -3
- package/dist/lib/modules/core/schema/directory-frames.js +8 -3
- package/dist/lib/modules/core/schema/errors.js +3 -2
- package/dist/lib/modules/core/schema/ipc/consent.js +85 -10
- package/dist/lib/modules/core/schema/ipc/conversation-views.js +8 -1
- package/dist/lib/modules/core/schema/ipc/conversations.js +5 -1
- package/dist/lib/modules/core/schema/ipc/device.js +7 -0
- package/dist/lib/modules/core/schema/ipc/jelly.js +14 -7
- package/dist/lib/modules/core/schema/ipc/shapes.js +60 -1
- package/dist/lib/modules/core/schema/ipc.js +51 -3
- package/dist/lib/modules/core/version.js +1 -1
- package/dist/spec/messaging-v1.md +321 -49
- package/dist/types/core/agent/present.d.ts +1 -1
- package/dist/types/core/agent-tools.d.ts +32 -0
- package/dist/types/core/consent.d.ts +30 -7
- package/dist/types/core/contact-card.d.ts +3 -3
- package/dist/types/core/conversation-agent-tools.d.ts +21 -0
- package/dist/types/core/index.d.ts +1 -1
- package/dist/types/core/schema/common.d.ts +2 -0
- package/dist/types/core/schema/consent.d.ts +23 -2
- package/dist/types/core/schema/conv-frames.d.ts +6 -2
- package/dist/types/core/schema/directory-frames.d.ts +8 -3
- package/dist/types/core/schema/envelope.d.ts +2 -0
- package/dist/types/core/schema/errors.d.ts +2 -0
- package/dist/types/core/schema/ipc/consent.d.ts +377 -15
- package/dist/types/core/schema/ipc/conversation-views.d.ts +176 -0
- package/dist/types/core/schema/ipc/conversations.d.ts +368 -0
- package/dist/types/core/schema/ipc/device.d.ts +6 -0
- package/dist/types/core/schema/ipc/jelly.d.ts +14 -6
- package/dist/types/core/schema/ipc/shapes.d.ts +198 -0
- package/dist/types/core/schema/ipc/surfaces.d.ts +1 -0
- package/dist/types/core/schema/ipc/tasks.d.ts +2 -0
- package/dist/types/core/schema/ipc.d.ts +826 -3
- package/dist/types/core/schema/relay.d.ts +4 -0
- package/dist/types/core/version.d.ts +1 -1
- package/dist/types/ipc/server.d.ts +6 -0
- 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
|
-
*
|
|
130
|
-
* `
|
|
131
|
-
*
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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")
|
|
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
|
|
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";
|
|
@@ -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
|
-
/**
|
|
17
|
-
|
|
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
|
-
/**
|
|
86
|
-
|
|
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
|
|
163
|
-
*
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|