@intentic/sandbox-contract 1.337.0 → 1.339.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 (316) hide show
  1. package/README.md +41 -0
  2. package/dist/chores/probes.d.ts.map +1 -1
  3. package/dist/chores/probes.js +4 -7
  4. package/dist/chores/probes.js.map +1 -1
  5. package/dist/contracts/agent.contract.d.ts +32 -23
  6. package/dist/contracts/agent.contract.d.ts.map +1 -1
  7. package/dist/contracts/agents.contract.d.ts +6020 -5994
  8. package/dist/contracts/agents.contract.d.ts.map +1 -1
  9. package/dist/contracts/agents.contract.js +13 -1
  10. package/dist/contracts/agents.contract.js.map +1 -1
  11. package/dist/contracts/approvals.contract.d.ts +8 -0
  12. package/dist/contracts/approvals.contract.d.ts.map +1 -1
  13. package/dist/contracts/automations.contract.d.ts +3 -3
  14. package/dist/contracts/capabilities.contract.d.ts +28 -0
  15. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  16. package/dist/contracts/capabilities.contract.js +11 -1
  17. package/dist/contracts/capabilities.contract.js.map +1 -1
  18. package/dist/contracts/device.contract.d.ts +225 -0
  19. package/dist/contracts/device.contract.d.ts.map +1 -1
  20. package/dist/contracts/device.contract.js +10 -0
  21. package/dist/contracts/device.contract.js.map +1 -1
  22. package/dist/contracts/extensions.contract.d.ts +16 -0
  23. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  24. package/dist/contracts/needs.contract.d.ts +22 -7
  25. package/dist/contracts/needs.contract.d.ts.map +1 -1
  26. package/dist/contracts/org.contract.d.ts +35 -0
  27. package/dist/contracts/org.contract.d.ts.map +1 -0
  28. package/dist/contracts/org.contract.js +14 -0
  29. package/dist/contracts/org.contract.js.map +1 -0
  30. package/dist/contracts/phone.contract.d.ts +1 -0
  31. package/dist/contracts/phone.contract.d.ts.map +1 -1
  32. package/dist/contracts/privacy.contract.d.ts +115 -0
  33. package/dist/contracts/privacy.contract.d.ts.map +1 -1
  34. package/dist/contracts/privacy.contract.js +11 -2
  35. package/dist/contracts/privacy.contract.js.map +1 -1
  36. package/dist/contracts/runner.contract.d.ts +11 -7
  37. package/dist/contracts/runner.contract.d.ts.map +1 -1
  38. package/dist/contracts/secrets.contract.d.ts +85 -0
  39. package/dist/contracts/secrets.contract.d.ts.map +1 -1
  40. package/dist/contracts/secrets.contract.js +48 -1
  41. package/dist/contracts/secrets.contract.js.map +1 -1
  42. package/dist/contracts/sessions.contract.d.ts +4 -1
  43. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  44. package/dist/contracts/settings.contract.d.ts +11 -3
  45. package/dist/contracts/settings.contract.d.ts.map +1 -1
  46. package/dist/contracts/system.contract.d.ts +23 -2
  47. package/dist/contracts/system.contract.d.ts.map +1 -1
  48. package/dist/events/agent-events.d.ts +17 -4
  49. package/dist/events/agent-events.d.ts.map +1 -1
  50. package/dist/events/requests.d.ts +4 -0
  51. package/dist/events/requests.d.ts.map +1 -1
  52. package/dist/events/requests.js +1 -1
  53. package/dist/events/requests.js.map +1 -1
  54. package/dist/events/system-events.d.ts +4 -0
  55. package/dist/events/system-events.d.ts.map +1 -1
  56. package/dist/events/system-events.js +2 -0
  57. package/dist/events/system-events.js.map +1 -1
  58. package/dist/events/transcript.d.ts +27 -6
  59. package/dist/events/transcript.d.ts.map +1 -1
  60. package/dist/index.d.ts +7562 -7426
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +31 -257
  63. package/dist/index.js.map +1 -1
  64. package/dist/models/agent-lanes.d.ts +30 -0
  65. package/dist/models/agent-lanes.d.ts.map +1 -0
  66. package/dist/models/agent-lanes.js +67 -0
  67. package/dist/models/agent-lanes.js.map +1 -0
  68. package/dist/models/agent-standing.d.ts +55 -0
  69. package/dist/models/agent-standing.d.ts.map +1 -0
  70. package/dist/models/agent-standing.js +82 -0
  71. package/dist/models/agent-standing.js.map +1 -0
  72. package/dist/models/agent-starter.d.ts +10 -0
  73. package/dist/models/agent-starter.d.ts.map +1 -0
  74. package/dist/models/agent-starter.js +16 -0
  75. package/dist/models/agent-starter.js.map +1 -0
  76. package/dist/netd/generated/wire.d.ts +1 -1
  77. package/dist/netd/generated/wire.d.ts.map +1 -1
  78. package/dist/policy/identity-assertion.d.ts.map +1 -1
  79. package/dist/policy/identity-assertion.js +2 -8
  80. package/dist/policy/identity-assertion.js.map +1 -1
  81. package/dist/policy/keyring-seal.d.ts +48 -0
  82. package/dist/policy/keyring-seal.d.ts.map +1 -0
  83. package/dist/policy/keyring-seal.js +94 -0
  84. package/dist/policy/keyring-seal.js.map +1 -0
  85. package/dist/policy/message-limits.d.ts +2 -0
  86. package/dist/policy/message-limits.d.ts.map +1 -1
  87. package/dist/policy/message-limits.js +2 -0
  88. package/dist/policy/message-limits.js.map +1 -1
  89. package/dist/policy/org-locks.d.ts +58 -0
  90. package/dist/policy/org-locks.d.ts.map +1 -0
  91. package/dist/policy/org-locks.js +113 -0
  92. package/dist/policy/org-locks.js.map +1 -0
  93. package/dist/policy/persona-capability.d.ts +14 -0
  94. package/dist/policy/persona-capability.d.ts.map +1 -0
  95. package/dist/policy/persona-capability.js +23 -0
  96. package/dist/policy/persona-capability.js.map +1 -0
  97. package/dist/policy/reserved-servers.d.ts.map +1 -1
  98. package/dist/policy/reserved-servers.js +1 -1
  99. package/dist/policy/reserved-servers.js.map +1 -1
  100. package/dist/protocol/doors/phone-links.d.ts +5 -0
  101. package/dist/protocol/doors/phone-links.d.ts.map +1 -1
  102. package/dist/protocol/doors/phone-links.js +3 -1
  103. package/dist/protocol/doors/phone-links.js.map +1 -1
  104. package/dist/protocol/doors/webext-links.d.ts +3 -0
  105. package/dist/protocol/doors/webext-links.d.ts.map +1 -1
  106. package/dist/protocol/doors/webext-links.js +3 -0
  107. package/dist/protocol/doors/webext-links.js.map +1 -1
  108. package/dist/protocol/raw/raw-json-routes.d.ts +270 -0
  109. package/dist/protocol/raw/raw-json-routes.d.ts.map +1 -1
  110. package/dist/protocol/raw/raw-json-routes.js +14 -1
  111. package/dist/protocol/raw/raw-json-routes.js.map +1 -1
  112. package/dist/protocol/raw/raw-routes.d.ts +70 -0
  113. package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
  114. package/dist/protocol/raw/raw-routes.js +18 -0
  115. package/dist/protocol/raw/raw-routes.js.map +1 -1
  116. package/dist/protocol/route-meta.d.ts.map +1 -1
  117. package/dist/protocol/route-meta.js.map +1 -1
  118. package/dist/protocol/routes.d.ts +4 -0
  119. package/dist/protocol/routes.d.ts.map +1 -1
  120. package/dist/protocol/routes.js +12 -0
  121. package/dist/protocol/routes.js.map +1 -1
  122. package/dist/schemas/agents-list.d.ts +5 -0
  123. package/dist/schemas/agents-list.d.ts.map +1 -1
  124. package/dist/schemas/agents-list.js +8 -0
  125. package/dist/schemas/agents-list.js.map +1 -1
  126. package/dist/schemas/agents.d.ts +1 -0
  127. package/dist/schemas/agents.d.ts.map +1 -1
  128. package/dist/schemas/agents.js +1 -0
  129. package/dist/schemas/agents.js.map +1 -1
  130. package/dist/schemas/approvals.d.ts +20 -0
  131. package/dist/schemas/approvals.d.ts.map +1 -1
  132. package/dist/schemas/approvals.js +10 -0
  133. package/dist/schemas/approvals.js.map +1 -1
  134. package/dist/schemas/capabilities.d.ts +26 -0
  135. package/dist/schemas/capabilities.d.ts.map +1 -1
  136. package/dist/schemas/capabilities.js +17 -0
  137. package/dist/schemas/capabilities.js.map +1 -1
  138. package/dist/schemas/data-protection.d.ts +5 -0
  139. package/dist/schemas/data-protection.d.ts.map +1 -1
  140. package/dist/schemas/data-protection.js +29 -0
  141. package/dist/schemas/data-protection.js.map +1 -1
  142. package/dist/schemas/devices.d.ts +20 -0
  143. package/dist/schemas/devices.d.ts.map +1 -1
  144. package/dist/schemas/devices.js +5 -1
  145. package/dist/schemas/devices.js.map +1 -1
  146. package/dist/schemas/extension-updates.d.ts +32 -0
  147. package/dist/schemas/extension-updates.d.ts.map +1 -1
  148. package/dist/schemas/git/git.d.ts +19 -0
  149. package/dist/schemas/git/git.d.ts.map +1 -1
  150. package/dist/schemas/git/git.js +12 -1
  151. package/dist/schemas/git/git.js.map +1 -1
  152. package/dist/schemas/hosts.d.ts +2 -0
  153. package/dist/schemas/hosts.d.ts.map +1 -1
  154. package/dist/schemas/hosts.js +11 -1
  155. package/dist/schemas/hosts.js.map +1 -1
  156. package/dist/schemas/intentic-os.d.ts +218 -0
  157. package/dist/schemas/intentic-os.d.ts.map +1 -1
  158. package/dist/schemas/intentic-os.js +166 -0
  159. package/dist/schemas/intentic-os.js.map +1 -1
  160. package/dist/schemas/keyring.d.ts +421 -0
  161. package/dist/schemas/keyring.d.ts.map +1 -0
  162. package/dist/schemas/keyring.js +176 -0
  163. package/dist/schemas/keyring.js.map +1 -0
  164. package/dist/schemas/links.d.ts +208 -0
  165. package/dist/schemas/links.d.ts.map +1 -0
  166. package/dist/schemas/links.js +116 -0
  167. package/dist/schemas/links.js.map +1 -0
  168. package/dist/schemas/maintenance.d.ts.map +1 -1
  169. package/dist/schemas/maintenance.js.map +1 -1
  170. package/dist/schemas/members.d.ts.map +1 -1
  171. package/dist/schemas/members.js +2 -0
  172. package/dist/schemas/members.js.map +1 -1
  173. package/dist/schemas/needs.d.ts +25 -8
  174. package/dist/schemas/needs.d.ts.map +1 -1
  175. package/dist/schemas/needs.js +15 -3
  176. package/dist/schemas/needs.js.map +1 -1
  177. package/dist/schemas/organisation.d.ts +106 -0
  178. package/dist/schemas/organisation.d.ts.map +1 -0
  179. package/dist/schemas/organisation.js +52 -0
  180. package/dist/schemas/organisation.js.map +1 -0
  181. package/dist/schemas/phone.d.ts +3 -0
  182. package/dist/schemas/phone.d.ts.map +1 -1
  183. package/dist/schemas/phone.js +1 -0
  184. package/dist/schemas/phone.js.map +1 -1
  185. package/dist/schemas/privacy.d.ts +232 -0
  186. package/dist/schemas/privacy.d.ts.map +1 -1
  187. package/dist/schemas/privacy.js +154 -4
  188. package/dist/schemas/privacy.js.map +1 -1
  189. package/dist/schemas/secrets.d.ts +151 -0
  190. package/dist/schemas/secrets.d.ts.map +1 -1
  191. package/dist/schemas/secrets.js +71 -8
  192. package/dist/schemas/secrets.js.map +1 -1
  193. package/dist/schemas/settings.d.ts +8 -0
  194. package/dist/schemas/settings.d.ts.map +1 -1
  195. package/dist/schemas/settings.js +4 -0
  196. package/dist/schemas/settings.js.map +1 -1
  197. package/dist/schemas/speech.d.ts +12 -0
  198. package/dist/schemas/speech.d.ts.map +1 -1
  199. package/dist/schemas/speech.js +23 -0
  200. package/dist/schemas/speech.js.map +1 -1
  201. package/dist/schemas/system.d.ts +2 -0
  202. package/dist/schemas/system.d.ts.map +1 -1
  203. package/dist/schemas/system.js +1 -1
  204. package/dist/schemas/system.js.map +1 -1
  205. package/dist/schemas/terminal.d.ts +15 -0
  206. package/dist/schemas/terminal.d.ts.map +1 -1
  207. package/dist/schemas/terminal.js +11 -2
  208. package/dist/schemas/terminal.js.map +1 -1
  209. package/dist/schemas/voice.d.ts +3 -0
  210. package/dist/schemas/voice.d.ts.map +1 -1
  211. package/dist/schemas/voice.js +7 -0
  212. package/dist/schemas/voice.js.map +1 -1
  213. package/dist/schemas/webext.d.ts +17 -0
  214. package/dist/schemas/webext.d.ts.map +1 -1
  215. package/dist/schemas/webext.js +7 -1
  216. package/dist/schemas/webext.js.map +1 -1
  217. package/dist/state/contract-lock.d.ts +3 -1
  218. package/dist/state/contract-lock.d.ts.map +1 -1
  219. package/dist/state/contract-lock.js +34 -9
  220. package/dist/state/contract-lock.js.map +1 -1
  221. package/dist/state/contract-modules.d.ts +4 -0
  222. package/dist/state/contract-modules.d.ts.map +1 -0
  223. package/dist/state/contract-modules.js +28 -0
  224. package/dist/state/contract-modules.js.map +1 -0
  225. package/dist/state/daemon-floor.d.ts +4 -0
  226. package/dist/state/daemon-floor.d.ts.map +1 -0
  227. package/dist/state/daemon-floor.js +8 -0
  228. package/dist/state/daemon-floor.js.map +1 -0
  229. package/dist/state/definition.d.ts +34 -0
  230. package/dist/state/definition.d.ts.map +1 -1
  231. package/dist/state/history-state.d.ts.map +1 -1
  232. package/dist/state/history-state.js +8 -0
  233. package/dist/state/history-state.js.map +1 -1
  234. package/dist/text/documents.d.ts +3 -1
  235. package/dist/text/documents.d.ts.map +1 -1
  236. package/dist/text/documents.js +30 -0
  237. package/dist/text/documents.js.map +1 -1
  238. package/dist/text/pages.d.ts.map +1 -1
  239. package/dist/text/pages.js +3 -7
  240. package/dist/text/pages.js.map +1 -1
  241. package/package.json +27 -5
  242. package/src/chores/probes.ts +4 -6
  243. package/src/contracts/agents.contract.ts +18 -1
  244. package/src/contracts/capabilities.contract.ts +14 -0
  245. package/src/contracts/device.contract.ts +30 -0
  246. package/src/contracts/org.contract.ts +19 -0
  247. package/src/contracts/privacy.contract.ts +13 -1
  248. package/src/contracts/secrets.contract.ts +64 -0
  249. package/src/events/requests.ts +3 -1
  250. package/src/events/system-events.ts +9 -0
  251. package/src/index.ts +41 -279
  252. package/src/models/agent-lanes.ts +152 -0
  253. package/src/models/agent-standing.ts +279 -0
  254. package/src/models/agent-starter.ts +26 -0
  255. package/src/netd/generated/netd-wire.json +2 -1
  256. package/src/netd/generated/wire.ts +1 -1
  257. package/src/policy/identity-assertion.ts +2 -8
  258. package/src/policy/keyring-seal.test.ts +93 -0
  259. package/src/policy/keyring-seal.ts +148 -0
  260. package/src/policy/message-limits.ts +3 -0
  261. package/src/policy/org-locks.ts +173 -0
  262. package/src/policy/persona-capability.ts +57 -0
  263. package/src/policy/reserved-servers.ts +3 -2
  264. package/src/policy/tests/org-locks.test.ts +92 -0
  265. package/src/policy/tests/reserved-servers.test.ts +3 -2
  266. package/src/protocol/doors/listener-protocol.ts +1 -1
  267. package/src/protocol/doors/phone-links.ts +11 -2
  268. package/src/protocol/doors/phone-protocol.test.ts +8 -1
  269. package/src/protocol/doors/webext-links.ts +13 -0
  270. package/src/protocol/ingress-contract.fixture.json +2 -0
  271. package/src/protocol/pairing-redeem.ts +1 -1
  272. package/src/protocol/peer-dial.test.ts +6 -6
  273. package/src/protocol/peer-mcp-server.test.ts +2 -1
  274. package/src/protocol/raw/raw-json-routes.ts +31 -1
  275. package/src/protocol/raw/raw-routes.ts +35 -1
  276. package/src/protocol/route-meta.ts +2 -1
  277. package/src/protocol/routes.test.ts +33 -1
  278. package/src/protocol/routes.ts +18 -0
  279. package/src/protocol/sse.ts +1 -1
  280. package/src/schemas/agent.ts +1 -1
  281. package/src/schemas/agents-list.ts +13 -0
  282. package/src/schemas/agents.ts +1 -0
  283. package/src/schemas/approvals.ts +14 -0
  284. package/src/schemas/capabilities.ts +25 -0
  285. package/src/schemas/data-protection.ts +47 -0
  286. package/src/schemas/devices.ts +15 -1
  287. package/src/schemas/git/git.ts +22 -2
  288. package/src/schemas/hosts.ts +14 -1
  289. package/src/schemas/intentic-os.test.ts +148 -0
  290. package/src/schemas/intentic-os.ts +259 -0
  291. package/src/schemas/keyring.ts +277 -0
  292. package/src/schemas/links.ts +180 -0
  293. package/src/schemas/maintenance.ts +1 -0
  294. package/src/schemas/members.ts +4 -0
  295. package/src/schemas/needs.ts +21 -5
  296. package/src/schemas/organisation.ts +110 -0
  297. package/src/schemas/phone.ts +3 -0
  298. package/src/schemas/privacy.test.ts +63 -0
  299. package/src/schemas/privacy.ts +213 -5
  300. package/src/schemas/secrets.ts +96 -8
  301. package/src/schemas/settings.ts +8 -0
  302. package/src/schemas/speech.ts +38 -1
  303. package/src/schemas/system.ts +5 -2
  304. package/src/schemas/terminal.ts +13 -2
  305. package/src/schemas/voice.ts +11 -0
  306. package/src/schemas/webext.ts +16 -1
  307. package/src/state/contract-lock.test.ts +12 -12
  308. package/src/state/contract-lock.ts +43 -12
  309. package/src/state/contract-modules.ts +41 -0
  310. package/src/state/daemon-floor.test.ts +81 -0
  311. package/src/state/daemon-floor.ts +32 -0
  312. package/src/state/history-state.ts +17 -0
  313. package/src/state/workspace-state.ts +1 -1
  314. package/src/text/documents.test.ts +30 -1
  315. package/src/text/documents.ts +43 -1
  316. package/src/text/pages.ts +5 -7
@@ -0,0 +1,279 @@
1
+ import {
2
+ type AgentAttention,
3
+ type AgentJob,
4
+ type AgentStatus,
5
+ type AgentWatch,
6
+ awaitsWake,
7
+ type LandConflictReason,
8
+ type LandFailure,
9
+ } from "../schemas/agents.js";
10
+ import type { QueuePause } from "../schemas/agent.js";
11
+
12
+ // (2026-10-16) Moved here from the editor (_editor/web/src/models/agent-status/agentStatus-standing.ts, which re-exports
13
+ // it), so the daemon counts what needs the person by the very rules the board draws with (models/agent-lanes.ts): the
14
+ // desktop app's status bar asks the daemon (`agents.attention`), and the board and the bar must never disagree.
15
+
16
+ // Every projection of a fleet agent's state (lane, attention label, drill-in verb, glyphs). Nothing else may
17
+ // derive these from `status` alone: a parked turn is `idle` with an attention flag raised. Pure functions over
18
+ // plain data, no store, no Vue.
19
+
20
+ // Four standings a conversation gets when the daemon has not registered it. `draft`: not sent yet. `starting`:
21
+ // sent but not yet filed as an agent. `failed`: send refused, never became an agent. `resumed`: a past conversation
22
+ // reopened from History with no surviving registry entry. All four have no registry entry, so nothing to archive,
23
+ // review, land or drop (`unregistered` below). None may be widened into `AgentStatus`: that enum is only the
24
+ // daemon's account of agents it has.
25
+ export type ClientAgentStatus = "draft" | "starting" | "failed" | "resumed";
26
+
27
+ // Enough of an agent to place one; every predicate below takes only this, so a FleetAgent, a roster AgentSummary,
28
+ // or a test literal can all answer the same question. Each optional field also takes `undefined`, as the contract's own
29
+ // AgentSummary spells it, so the daemon (strict about the difference) hands its roster over as it is.
30
+ export interface AgentStanding {
31
+ readonly status: AgentStatus | ClientAgentStatus;
32
+ readonly attention: AgentAttention;
33
+ // Outside conditions this conversation is parked on (AgentSummary.watches); `laneOf` reads it alongside `status`
34
+ // and `attention`. Absent for nearly every conversation.
35
+ readonly watches?: readonly AgentWatch[] | undefined;
36
+ // Whether it runs again by itself (AgentSummary.awaitingWake): the daemon's one reading, which `watching` asks.
37
+ readonly awaitingWake?: boolean | undefined;
38
+ // The commands it left running and how the recent ones ended (AgentSummary.jobs); `heldByPrompt` reads which of its
39
+ // watches a job at a prompt holds. Absent for nearly every conversation.
40
+ readonly jobs?: readonly AgentJob[] | undefined;
41
+ // Why a refused land is still refusing (AgentSummary.conflictCauses), which decides whose press can clear it.
42
+ // Absent for every card but one refusing to merge.
43
+ readonly conflictCauses?: readonly LandConflictReason[] | undefined;
44
+ // Which kind of failure ended the last turn. `status: "error"` alone conflates a broken harness with a spent
45
+ // allowance; both belong in Attention, but this says which so the card can tell them apart. `laneOf` reads it too,
46
+ // so the lane, badge and chip stay in agreement.
47
+ readonly failureCode?: string | undefined;
48
+ // The words that turn died on (AgentSummary.failure), cleared with the code when it runs again.
49
+ readonly failure?: string | undefined;
50
+ /** When the spent allowance reopens, in epoch SECONDS (the wire's unit). Absent when nobody published one. */
51
+ readonly limitResetsAt?: number | undefined;
52
+ /** Whether the refused turn is held whole, so a press re-runs it rather than sending a new message after it. */
53
+ readonly limitHeld?: boolean | undefined;
54
+ /** Whether a fire is already booked for it (at the reset, or a move now), so no press is needed. */
55
+ readonly limitScheduled?: boolean | undefined;
56
+ /** The account the owner's policy is moving the held turn to, while that move is booked (a scheduled card's sentence). */
57
+ readonly limitMoving?: string | undefined;
58
+ // A land that broke (AgentSummary.landFailure), standing until one goes through whatever turns run after it. Absent
59
+ // from a sandbox older than it, where only a turn's own `failure` can say so.
60
+ readonly landFailure?: LandFailure | undefined;
61
+ // What waits for its next turn (AgentSummary.queue), read here only for its hold: messages booked to go by
62
+ // themselves at a time or once another agent's work lands (`scheduled`), which make the conversation one that runs
63
+ // again by itself. Never the words.
64
+ readonly queue?: QueueHold | undefined;
65
+ }
66
+
67
+ // The part of a conversation's queue the board reads: why it holds, and what a scheduled hold waits for. A queue-level
68
+ // `scheduled` with its `until` or `after` is the soonest booked message's; a Stop's or a refusal's hold on the messages
69
+ // with no booking takes that place, so each message's own booking (`items`, from sandboxes that book each message on its
70
+ // own) is read then.
71
+ export interface QueueHold {
72
+ readonly paused?: QueuePause | undefined;
73
+ readonly until?: number | undefined;
74
+ readonly after?: string | undefined;
75
+ readonly items?: readonly { readonly until?: number | undefined; readonly after?: string | undefined }[] | undefined;
76
+ }
77
+
78
+ // The soonest booking a queue holds, as a scheduled hold: the queue's own while it reads `scheduled`, else the earliest
79
+ // message's time, else the first message waiting on a land; undefined when nothing is booked. One thing, never both.
80
+ export const soonestHold = (queue: QueueHold | undefined): QueueHold | undefined => {
81
+ if (queue?.paused === "scheduled") {
82
+ if (queue.after !== undefined) {
83
+ return { paused: "scheduled", after: queue.after };
84
+ }
85
+ return queue.until === undefined ? { paused: "scheduled" } : { paused: "scheduled", until: queue.until };
86
+ }
87
+ const items = queue?.items ?? [];
88
+ const instants = items.flatMap((item) => (item.until === undefined ? [] : [item.until]));
89
+ if (instants.length > 0) {
90
+ return { paused: "scheduled", until: Math.min(...instants) };
91
+ }
92
+ const after = items.find((item) => item.after !== undefined)?.after;
93
+ return after === undefined ? undefined : { paused: "scheduled", after };
94
+ };
95
+
96
+ // Messages booked to go by themselves (sendLater.ts, or a spent allowance's reopen): the conversation runs again with
97
+ // nobody pressing anything, so it is not finished, and nothing is owed by the reader for it to.
98
+ export const scheduledSend = (agent: Pick<AgentStanding, "queue">): boolean => soonestHold(agent.queue) !== undefined;
99
+
100
+ // How many messages are booked to go by themselves: each by its own booking, or all of them when an older sandbox booked
101
+ // the queue whole. What an archive by hand cancels (the sandbox's archive.ts), which the card's Archive says first.
102
+ export const scheduledCount = (agent: Pick<AgentStanding, "queue">): number => {
103
+ const items = agent.queue?.items ?? [];
104
+ return agent.queue?.paused === "scheduled" ? items.length : items.filter((item) => item.until !== undefined || item.after !== undefined).length;
105
+ };
106
+
107
+ // A spent allowance, not a failure to fix: nothing broken, and it comes back. Read off `failureCode` rather than
108
+ // the provider's own sentence, which varies per provider; the code is the daemon's own classification
109
+ // (error-frames.ts).
110
+ export const limited = (agent: AgentStanding): boolean => agent.status === "error" && agent.failureCode === "rate_limit";
111
+
112
+ // A message held at the door because the sandbox is short of memory (turn-admission): nothing ran and nothing failed,
113
+ // it waits for a person's "send anyway". The daemon files it as an `error`, so this is read off the code, as `limited`
114
+ // is, and the chip and glyph say held rather than error; the lane stays Attention, since the press is the reader's.
115
+ export const memoryHeld = (agent: Pick<AgentStanding, "status" | "failureCode">): boolean =>
116
+ agent.status === "error" && agent.failureCode === "sandbox-memory-low";
117
+
118
+ // Whether the window is still shut, feeding only the card's countdown, never the lane: a stranded card stays in
119
+ // Attention shut or open. No published instant means never closed, not a guessed one.
120
+ export const limitClosed = (agent: AgentStanding, now: number = Date.now()): boolean =>
121
+ limited(agent) && agent.limitResetsAt !== undefined && agent.limitResetsAt * 1_000 > now;
122
+
123
+ // A booked fire needs no press: the held turn goes again at the reset by itself. Only where this conversation's answer
124
+ // for the limit says so (`resend` or `move`); that answer starts at `wait`. Nothing is owed then, so it leaves Attention
125
+ // for Active, as an armed watch does: a card counted in the badge and the tab title for the hours until a reset the
126
+ // reader already answered was a call nobody could answer. The stall still shows, on the card's own chip and clock.
127
+ export const limitScheduled = (agent: AgentStanding): boolean => limited(agent) && agent.limitScheduled === true;
128
+
129
+ // A booked move: the owner's policy is already carrying the held turn to another account, on the resume pass's next
130
+ // beat, seconds away, so the card names where it is going rather than when it goes.
131
+ export const limitMoveBooked = (agent: AgentStanding): boolean => limitScheduled(agent) && agent.limitMoving !== undefined;
132
+
133
+ // A refused land nothing the agent does can clear: every blocker left is a file the user has uncommitted edits on,
134
+ // which only a commit or stash releases (git cannot merge through unstaged work, and the agent's checkout cannot
135
+ // even see it). The distinction the board used to lack: it offered "have the agent resolve it" for every conflict,
136
+ // including the ones where that press was refused the moment it was made.
137
+ // False when the daemon named no causes, which is a build that predates them or a refusal it could not attribute;
138
+ // the board then falls back to offering the agent, as it always did.
139
+ export const conflictIsYours = (agent: AgentStanding): boolean =>
140
+ agent.conflictCauses !== undefined && agent.conflictCauses.length > 0 && agent.conflictCauses.every((cause) => cause === "workspace");
141
+
142
+ // This conversation runs again by itself, with nobody pressing anything: its last turn ended (status is `idle`/`landed`,
143
+ // never `ready`, since the daemon holds the land for the wake), but the conversation is not over. The daemon's own
144
+ // reading (a watch, or a wake waiting in its queue); a daemon older than it is read by its watches.
145
+ export const watching = (agent: AgentStanding): boolean => awaitsWake(agent);
146
+
147
+ // The running job behind one of its armed watches that sits at a prompt (AgentJob.inputWait: blocked reading its
148
+ // terminal, nothing moving), the first such where there are several. Its watch fires only on an exit that will not
149
+ // come, so the card names it and offers to end it.
150
+ export const promptHeldJob = (agent: AgentStanding): AgentJob | undefined => {
151
+ const armed = new Set((agent.watches ?? []).map((watch) => watch.id));
152
+ return agent.jobs?.find((job) => job.endedAt === undefined && job.inputWait !== undefined && job.watch !== undefined && armed.has(job.watch));
153
+ };
154
+
155
+ // Parked only on jobs at a prompt: every watch it holds waits on an exit nobody in the sandbox will bring about, so the
156
+ // conversation is not resuming by itself, it is stuck until the reader ends the command (or types into its terminal).
157
+ // Not while a turn runs, whose own readout says what it is doing. On 2026-10-04 such a card sat in Active for hours
158
+ // under a six-hour countdown, with its Land press hidden behind the wake it was waiting for.
159
+ export const heldByPrompt = (agent: AgentStanding): boolean => {
160
+ const watches = agent.watches ?? [];
161
+ if (watches.length === 0 || turnInFlight(agent)) {
162
+ return false;
163
+ }
164
+ const prompts = new Set(
165
+ (agent.jobs ?? [])
166
+ .filter((job) => job.endedAt === undefined && job.inputWait !== undefined && job.watch !== undefined)
167
+ .map((job) => job.watch),
168
+ );
169
+ return watches.every((watch) => prompts.has(watch.id));
170
+ };
171
+
172
+ // No registry entry behind this card: archiving, reviewing, landing, discarding and dropping all address an agent
173
+ // by id through the daemon, and none of these ids name anything there.
174
+ // Takes the status alone, unlike the lane predicates: callers like the tab's `open` and the detail page's
175
+ // `registered` hold a status without an attention block to pair it with.
176
+ export const unregistered = (status: AgentStatus | ClientAgentStatus): boolean =>
177
+ status === "draft" || status === "starting" || status === "failed" || status === "resumed";
178
+
179
+ // A turn is in flight: running, unwinding after a Stop, or repairing itself after its daemon died. Every hands-off
180
+ // guard (its worktree is a live turn's working state) and the live readouts (elapsed, activity line) key off this.
181
+ // `starting` counts even though nothing is registered yet, elapsed should tick from the send. `dismissing` and
182
+ // `landing` count for the guards only; both settle in Finished, so their lane is decided separately (see laneOf).
183
+ // A land runs no turn, but the daemon holds the worktree exactly as a turn would, and every guard wants the same answer.
184
+ // `awaiting` is deliberately excluded: it's live but parked, handled by `awaitingUser` instead.
185
+ export const turnInFlight = (agent: AgentStanding): boolean =>
186
+ agent.status === "running" ||
187
+ agent.status === "starting" ||
188
+ agent.status === "stopping" ||
189
+ agent.status === "dismissing" ||
190
+ agent.status === "resuming" ||
191
+ agent.status === "landing";
192
+
193
+ // The two standings a person's ending unwinds as, from the press until the turn has let go: Stop, or waving away the
194
+ // question it was parked on. Both differ only in the lane they settle in (see laneOf).
195
+ export type EndingByHand = Extract<AgentStatus, "stopping" | "dismissing">;
196
+
197
+ // How a person ended this turn, while it unwinds; undefined for a turn nobody ended. The one reading both the board's
198
+ // card and the chat's live line are drawn from, so the two say the same word about the same press.
199
+ export const endingOf = (agent: AgentStanding): EndingByHand | undefined =>
200
+ agent.status === "stopping" || agent.status === "dismissing" ? agent.status : undefined;
201
+
202
+ // A person already ended this turn and it's unwinding. Narrower than `turnInFlight`, which also covers turns nobody
203
+ // ended.
204
+ export const endingByHand = (agent: AgentStanding): boolean => endingOf(agent) !== undefined;
205
+
206
+ // A turn actually producing something, as a card's readouts mean it: `landing` is in flight for the hands-off guards
207
+ // but spends no model, and its `startedAt` belongs to the turn before it, so an elapsed clock would be someone else's.
208
+ // A turn a person already ended produces nothing more either: its readout is the ending itself (the card's chip, the
209
+ // chat's line), not the step it was on under a clock still counting, which reads as a Stop that did not take.
210
+ export const turnWorking = (agent: AgentStanding): boolean => turnInFlight(agent) && agent.status !== "landing" && !endingByHand(agent);
211
+
212
+ // The browser's copy of the daemon's `writing` guard (agents-registry.ts); exists for Land, where typing vs.
213
+ // parked matters (everything else treats them alike). `stopping` and `resuming` are excluded because the provider
214
+ // isn't producing anything then; the two sides must stay in step or the UI offers a press the daemon refuses.
215
+ export const writingNow = (agent: AgentStanding): boolean => agent.status === "running" || agent.status === "starting";
216
+
217
+ // "Blocked on you": the agent cannot proceed (or has failed) until the user acts. Not the same as unread.
218
+ // `stopped`/`interrupted` leave a half-written worktree only a new message can carry forward; `stopping` is
219
+ // counted the moment the press lands rather than once the unwind finishes, since it settles in the same lane
220
+ // either way; `failed` is the same kind of dead end one step earlier, before a worktree existed. A spent allowance
221
+ // counts until something is booked to carry it on (limitScheduled): a resend at the reset, or a move now.
222
+ const BLOCKING_ENDINGS: ReadonlySet<AgentStatus | ClientAgentStatus> = new Set(["error", "interrupted", "stopping", "stopped", "failed"]);
223
+
224
+ // A land that broke and still stands: its work is stuck on the branch, whatever the turn after it did, so the card is
225
+ // the reader's to look at. Not while a turn or a land is under way, which ends in another land of its own.
226
+ export const landBroken = (agent: AgentStanding): boolean => agent.landFailure !== undefined && !turnInFlight(agent);
227
+
228
+ export const blocked = (agent: AgentStanding): boolean =>
229
+ limitScheduled(agent)
230
+ ? false
231
+ : agent.attention.plan ||
232
+ agent.attention.question ||
233
+ agent.attention.permission ||
234
+ agent.attention.capability ||
235
+ agent.attention.conflict ||
236
+ // A need outlives its turn (docs/architecture/needs.md): a conversation still running carries on beside it
237
+ // and stays in Active, one that stopped is waiting on nothing but the answer.
238
+ (agent.attention.need === true && agent.status !== "running") ||
239
+ landBroken(agent) ||
240
+ heldByPrompt(agent) ||
241
+ BLOCKING_ENDINGS.has(agent.status);
242
+
243
+ // The half of `blocked` that is literally waiting on an answer (plan, question, permission, capability), narrower
244
+ // than `blocked`: dead ends (a failed turn, an unlandable conflict) want looking at but owe the user nothing, so
245
+ // ending the agent loses no answer.
246
+ export const awaitingUser = (agent: AgentStanding): boolean =>
247
+ agent.attention.plan || agent.attention.question || agent.attention.permission || agent.attention.capability || agent.status === "awaiting";
248
+
249
+ // An ask only the owner may answer: a plan to approve, a permission, a setup, a credential release, a hand-off to a
250
+ // browser or terminal (a bare `awaiting`), or a refused land only their own commit clears. The parent a child agent
251
+ // works for can answer its question (subagents/children.ts) and hears how its turns end, but none of these, so these
252
+ // are what reach the reader from a child whatever its parent is doing (callsOwner).
253
+ export const onlyOwnerCanAnswer = (agent: AgentStanding): boolean =>
254
+ agent.attention.plan ||
255
+ agent.attention.permission ||
256
+ agent.attention.capability ||
257
+ agent.attention.credential ||
258
+ agent.attention.need === true ||
259
+ (agent.status === "awaiting" && !agent.attention.question) ||
260
+ ((agent.attention.conflict || agent.status === "conflict") && conflictIsYours(agent));
261
+
262
+ // Whether a child agent's stop is news its parent has already had. Every ending of a turn the parent started reaches
263
+ // the parent (subagents/child-report.ts: a parked `wait` takes it, or it wakes the parent like any wake), so a parent
264
+ // that moved after the child stopped (ran a turn, landed) heard it, and its own ending is the account the reader gets:
265
+ // an orchestrator that worked around three spent allowances and landed is done, not stuck. Only a turn's ending
266
+ // counts. A question is still parked and waits for an answer whoever has moved since, and a land refused or broken
267
+ // leaves work stuck on the child's branch that no report settles. A turn a person ran in the child's own chat is never
268
+ // reported to the parent, and it ends after the parent's last move, so it still reads as unheard here.
269
+ // `updatedAt` is the clock on both sides: a rename keeps it (agents-registry), and reading does not move it.
270
+ export const heardByParent = (child: AgentStanding & { readonly updatedAt: number }, parentAt: number): boolean =>
271
+ BLOCKING_ENDINGS.has(child.status) &&
272
+ // Still unwinding: its ending has not reached anyone yet.
273
+ child.status !== "stopping" &&
274
+ // Booked to run again by itself: not over, whatever its parent made of the pause.
275
+ !limitScheduled(child) &&
276
+ !child.attention.question &&
277
+ !child.attention.conflict &&
278
+ !landBroken(child) &&
279
+ child.updatedAt < parentAt;
@@ -0,0 +1,26 @@
1
+ // Who started a conversation when it was not a person, read off the summary's `startedBy` (the daemon's
2
+ // auth/principal.ts writes these prefixes). Moved here from the editor's ownership.ts (2026-10-16), which re-exports it,
3
+ // because which card a child agent rides under (models/agent-lanes.ts) is the daemon's count too.
4
+
5
+ // A starter that is not a person: a program holding a control token, or the conversation that spawned this one.
6
+ export type StarterLook = { readonly kind: "token"; readonly label: string } | { readonly kind: "child"; readonly parent: string };
7
+
8
+ const TOKEN_PREFIX = "token:";
9
+ const CHILD_PREFIX = "agent:";
10
+
11
+ export const starterLook = (startedBy: string | undefined): StarterLook | undefined => {
12
+ if (startedBy?.startsWith(TOKEN_PREFIX) === true) {
13
+ return { kind: "token", label: startedBy.slice(TOKEN_PREFIX.length) };
14
+ }
15
+ if (startedBy?.startsWith(CHILD_PREFIX) === true) {
16
+ return { kind: "child", parent: startedBy.slice(CHILD_PREFIX.length) };
17
+ }
18
+ return undefined;
19
+ };
20
+
21
+ // The conversation that spawned this one, when one did. A fact apart from whose it is: a child inherits its parent's
22
+ // owner, so on the reader's own work an ownership mark would never get to say it.
23
+ export const parentOf = (startedBy: string | undefined): string | undefined => {
24
+ const look = starterLook(startedBy);
25
+ return look?.kind === "child" ? look.parent : undefined;
26
+ };
@@ -648,7 +648,8 @@
648
648
  },
649
649
  "headers": [
650
650
  "x-intentic-preview",
651
- "x-intentic-preview-unreachable"
651
+ "x-intentic-preview-unreachable",
652
+ "x-intentic-listener"
652
653
  ],
653
654
  "toNode": {
654
655
  "$defs": {
@@ -41,7 +41,7 @@ export type NetdAnswer = { "answer": "sync", generations: Array<number | null>,
41
41
  * Request headers only netd sets on what it forwards to Node; it strips them from everything arriving outside.
42
42
  * Exported as a literal type, so a Node constant naming one that drifts from this spelling fails to compile.
43
43
  */
44
- export type NetdHeader = "x-intentic-preview" | "x-intentic-preview-unreachable";
44
+ export type NetdHeader = "x-intentic-preview" | "x-intentic-preview-unreachable" | "x-intentic-listener";
45
45
 
46
46
  /**
47
47
  * A question Node asks netd.
@@ -1,4 +1,5 @@
1
1
  import { createPrivateKey, createPublicKey, randomBytes, sign as edSign, verify as edVerify } from "node:crypto";
2
+ import { parseJsonWith } from "@intentic/base/json";
2
3
  import { z } from "zod";
3
4
  import { SANDBOX_ID } from "../ids/tunnel-ids.js";
4
5
 
@@ -92,14 +93,7 @@ const AssertionPayloadSchema = z.object({
92
93
  jti: z.string().min(16),
93
94
  });
94
95
 
95
- const parsePayload = (payload: Buffer): z.infer<typeof AssertionPayloadSchema> | undefined => {
96
- try {
97
- return AssertionPayloadSchema.safeParse(JSON.parse(payload.toString("utf8"))).data;
98
- } catch {
99
- // allow(silent-catch): a payload that is not JSON is an invalid assertion, which undefined is how this says.
100
- return undefined;
101
- }
102
- };
96
+ const parsePayload = (payload: Buffer): z.infer<typeof AssertionPayloadSchema> | undefined => parseJsonWith(payload.toString("utf8"), AssertionPayloadSchema);
103
97
 
104
98
  // Verifies an assertion against the platform's public key at nowMs; undefined for any way of being invalid (wrong
105
99
  // prefix, bad signature, malformed claim, expired, issued in the future, living longer than the daemon allows). The
@@ -0,0 +1,93 @@
1
+ import type { KeyringGrant } from "../schemas/keyring.js";
2
+ import { grantNonce, keyFingerprint, newSealKeyPair, newSignKeyPair, seal, signGrant, unseal, verifyGrant } from "./keyring-seal.js";
3
+
4
+ const SANDBOX = "https://a1b2c3d4e5f6.intentic.dev";
5
+
6
+ const grantAt = (now: number, over: Partial<KeyringGrant> = {}): KeyringGrant => ({
7
+ v: 1,
8
+ sandbox: SANDBOX,
9
+ secret: "VPN_FORTI",
10
+ target: { kind: "window", programs: ["FortiClient.exe"], anyField: false },
11
+ purpose: "agent",
12
+ detail: "FortiClient sign-in",
13
+ nonce: grantNonce(),
14
+ issuedAt: now,
15
+ expiresAt: now + 60_000,
16
+ ...over,
17
+ });
18
+
19
+ describe("sealed boxes", () => {
20
+ test("open for their own key and purpose", () => {
21
+ const pair = newSealKeyPair();
22
+ const purpose = { kind: "release", sandbox: SANDBOX, secret: "VPN_FORTI", nonce: grantNonce() } as const;
23
+ const box = seal(pair.publicKey, "hunter2-but-longer", purpose);
24
+ expect(unseal(pair, box, purpose)).toBe("hunter2-but-longer");
25
+ });
26
+
27
+ test("refuse another key", () => {
28
+ const purpose = { kind: "inbox", sandbox: SANDBOX, secret: "X" } as const;
29
+ const box = seal(newSealKeyPair().publicKey, "value", purpose);
30
+ expect(() => unseal(newSealKeyPair(), box, purpose)).toThrow();
31
+ });
32
+
33
+ test("refuse another purpose, so one release cannot stand in for another", () => {
34
+ const pair = newSealKeyPair();
35
+ const nonce = grantNonce();
36
+ const box = seal(pair.publicKey, "value", { kind: "release", sandbox: SANDBOX, secret: "A", nonce });
37
+ expect(() => unseal(pair, box, { kind: "release", sandbox: SANDBOX, secret: "B", nonce })).toThrow();
38
+ expect(() => unseal(pair, box, { kind: "type", sandbox: SANDBOX, secret: "A", nonce })).toThrow();
39
+ });
40
+
41
+ test("are fresh every time, so equal values do not look equal", () => {
42
+ const pair = newSealKeyPair();
43
+ const purpose = { kind: "inbox", sandbox: SANDBOX, secret: "X" } as const;
44
+ expect(seal(pair.publicKey, "same", purpose).data).not.toBe(seal(pair.publicKey, "same", purpose).data);
45
+ });
46
+ });
47
+
48
+ describe("grants", () => {
49
+ test("verify under the signing key", () => {
50
+ const pair = newSignKeyPair();
51
+ const now = Date.now();
52
+ const verdict = verifyGrant(pair.publicKey, signGrant(pair, grantAt(now)), now + 1000);
53
+ expect(verdict.ok).toBe(true);
54
+ expect(verdict.ok && verdict.grant.secret).toBe("VPN_FORTI");
55
+ });
56
+
57
+ test("refuse another sandbox's key", () => {
58
+ const now = Date.now();
59
+ const verdict = verifyGrant(newSignKeyPair().publicKey, signGrant(newSignKeyPair(), grantAt(now)), now);
60
+ expect(verdict).toMatchObject({ ok: false, code: "bad-grant" });
61
+ });
62
+
63
+ test("refuse a payload changed after signing", () => {
64
+ const pair = newSignKeyPair();
65
+ const now = Date.now();
66
+ const signed = signGrant(pair, grantAt(now));
67
+ // SAFETY: the payload is the grant signGrant just serialised, so it parses back to one.
68
+ const body = JSON.parse(Buffer.from(signed.payload, "base64url").toString("utf8")) as KeyringGrant;
69
+ const changed = Buffer.from(JSON.stringify({ ...body, secret: "BANK" }), "utf8").toString("base64url");
70
+ expect(verifyGrant(pair.publicKey, { ...signed, payload: changed }, now)).toMatchObject({ ok: false, code: "bad-grant" });
71
+ });
72
+
73
+ test("expire", () => {
74
+ const pair = newSignKeyPair();
75
+ const now = Date.now();
76
+ expect(verifyGrant(pair.publicKey, signGrant(pair, grantAt(now)), now + 61_000)).toMatchObject({ ok: false, code: "expired" });
77
+ });
78
+
79
+ test("are never signed for longer than two minutes", () => {
80
+ const now = Date.now();
81
+ expect(() => signGrant(newSignKeyPair(), grantAt(now, { expiresAt: now + 5 * 60_000 }))).toThrow();
82
+ });
83
+
84
+ test("from far in the future are not this sandbox's", () => {
85
+ const pair = newSignKeyPair();
86
+ const now = Date.now();
87
+ expect(verifyGrant(pair.publicKey, signGrant(pair, grantAt(now + 10 * 60_000)), now)).toMatchObject({ ok: false, code: "bad-grant" });
88
+ });
89
+ });
90
+
91
+ test("fingerprints read in groups of four", () => {
92
+ expect(keyFingerprint(newSealKeyPair().publicKey)).toMatch(/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/u);
93
+ });
@@ -0,0 +1,148 @@
1
+ import { createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, sign, verify, type KeyObject } from "node:crypto";
2
+ import { z } from "zod";
3
+ import {
4
+ KEYRING_GRANT_TTL_MAX_MS,
5
+ KEYRING_KEY_RE,
6
+ KeyringGrantSchema,
7
+ type KeyringGrant,
8
+ type SealedBox,
9
+ SealedBoxSchema,
10
+ type SignedKeyringGrant,
11
+ } from "../schemas/keyring.js";
12
+
13
+ // The two primitives a device-held secret rides on (schemas/keyring.ts): a SEALED BOX, a value only the holder of one
14
+ // X25519 key can open, and a GRANT, the sandbox's Ed25519-signed word for one use of one value. Shared by the daemon,
15
+ // which signs grants and opens what is sealed to it, and the keyring, which verifies grants and seals; one file so the
16
+ // two cannot drift on an encoding.
17
+
18
+ const SEAL_INFO = Buffer.from("intentic-keyring/seal/v1", "utf8");
19
+ const GRANT_DOMAIN = Buffer.from("intentic-keyring/grant/v1\0", "utf8");
20
+ // A clock on either side may run a little ahead; a grant from much further in the future is not one this sandbox made.
21
+ const GRANT_SKEW_MS = 30_000;
22
+
23
+ // A key pair as it is stored: both halves raw, base64url, the `x` and `d` of their JWK.
24
+ export interface KeyringKeyPair {
25
+ readonly publicKey: string;
26
+ readonly privateKey: string;
27
+ }
28
+
29
+ type Curve = "X25519" | "Ed25519";
30
+
31
+ // The halves of an OKP key's JWK, which Node always writes for a private key it just made.
32
+ const OkpJwkSchema = z.object({ x: z.string().regex(KEYRING_KEY_RE), d: z.string().regex(KEYRING_KEY_RE) });
33
+
34
+ const pairOf = (curve: Curve): KeyringKeyPair => {
35
+ const { privateKey } = curve === "X25519" ? generateKeyPairSync("x25519") : generateKeyPairSync("ed25519");
36
+ const jwk = OkpJwkSchema.parse(privateKey.export({ format: "jwk" }));
37
+ return { publicKey: jwk.x, privateKey: jwk.d };
38
+ };
39
+
40
+ export const newSealKeyPair = (): KeyringKeyPair => pairOf("X25519");
41
+ export const newSignKeyPair = (): KeyringKeyPair => pairOf("Ed25519");
42
+
43
+ const publicKeyOf = (curve: Curve, x: string): KeyObject => createPublicKey({ key: { kty: "OKP", crv: curve, x }, format: "jwk" });
44
+ const privateKeyOf = (curve: Curve, pair: KeyringKeyPair): KeyObject =>
45
+ createPrivateKey({ key: { kty: "OKP", crv: curve, x: pair.publicKey, d: pair.privateKey }, format: "jwk" });
46
+
47
+ // What a box is sealed FOR, mixed into its authentication tag: a box opened under any other purpose fails, so a value
48
+ // released for one grant cannot be presented as another's, nor a typing payload as an inbox item.
49
+ export type SealPurpose =
50
+ | { readonly kind: "release"; readonly sandbox: string; readonly secret: string; readonly nonce: string }
51
+ | { readonly kind: "type"; readonly sandbox: string; readonly secret: string; readonly nonce: string }
52
+ | { readonly kind: "inbox"; readonly sandbox: string; readonly secret: string }
53
+ | { readonly kind: "copy"; readonly sandbox: string; readonly secret: string; readonly updatedAt: number }
54
+ | { readonly kind: "sync"; readonly account: string; readonly device: string; readonly revision: number };
55
+
56
+ export const sealAad = (purpose: SealPurpose): Buffer => {
57
+ switch (purpose.kind) {
58
+ case "release":
59
+ case "type":
60
+ return Buffer.from(`${purpose.kind}\0${purpose.sandbox}\0${purpose.secret}\0${purpose.nonce}`, "utf8");
61
+ case "inbox":
62
+ return Buffer.from(`inbox\0${purpose.sandbox}\0${purpose.secret}`, "utf8");
63
+ case "copy":
64
+ return Buffer.from(`copy\0${purpose.sandbox}\0${purpose.secret}\0${String(purpose.updatedAt)}`, "utf8");
65
+ case "sync":
66
+ return Buffer.from(`sync\0${purpose.account}\0${purpose.device}\0${String(purpose.revision)}`, "utf8");
67
+ }
68
+ };
69
+
70
+ const boxKey = (shared: Buffer, epk: string, recipient: string): Buffer =>
71
+ Buffer.from(hkdfSync("sha256", shared, Buffer.concat([Buffer.from(epk, "base64url"), Buffer.from(recipient, "base64url")]), SEAL_INFO, 32));
72
+
73
+ // Seals `plaintext` to the holder of `recipient`'s private half, for `purpose` alone.
74
+ export const seal = (recipient: string, plaintext: string, purpose: SealPurpose): SealedBox => {
75
+ const ephemeral = newSealKeyPair();
76
+ const shared = diffieHellman({ privateKey: privateKeyOf("X25519", ephemeral), publicKey: publicKeyOf("X25519", recipient) });
77
+ const iv = randomBytes(12);
78
+ const cipher = createCipheriv("aes-256-gcm", boxKey(shared, ephemeral.publicKey, recipient), iv);
79
+ cipher.setAAD(sealAad(purpose));
80
+ const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
81
+ return { v: 1, epk: ephemeral.publicKey, iv: iv.toString("base64url"), tag: cipher.getAuthTag().toString("base64url"), data: data.toString("base64url") };
82
+ };
83
+
84
+ // Opens a box sealed to `pair` for `purpose`; throws on any way of not being one (malformed, another key, another purpose).
85
+ export const unseal = (pair: KeyringKeyPair, box: SealedBox, purpose: SealPurpose): string => {
86
+ const parsed = SealedBoxSchema.parse(box);
87
+ const shared = diffieHellman({ privateKey: privateKeyOf("X25519", pair), publicKey: publicKeyOf("X25519", parsed.epk) });
88
+ const decipher = createDecipheriv("aes-256-gcm", boxKey(shared, parsed.epk, pair.publicKey), Buffer.from(parsed.iv, "base64url"));
89
+ decipher.setAAD(sealAad(purpose));
90
+ decipher.setAuthTag(Buffer.from(parsed.tag, "base64url"));
91
+ return Buffer.concat([decipher.update(Buffer.from(parsed.data, "base64url")), decipher.final()]).toString("utf8");
92
+ };
93
+
94
+ // A fresh grant's nonce: 16 random bytes, base64url.
95
+ export const grantNonce = (): string => randomBytes(16).toString("base64url");
96
+
97
+ export const signGrant = (pair: KeyringKeyPair, grant: KeyringGrant): SignedKeyringGrant => {
98
+ const body = KeyringGrantSchema.parse(grant);
99
+ if (body.expiresAt - body.issuedAt > KEYRING_GRANT_TTL_MAX_MS || body.expiresAt <= body.issuedAt) {
100
+ throw new Error(`a keyring grant lives at most ${String(KEYRING_GRANT_TTL_MAX_MS / 1000)} s`);
101
+ }
102
+ const payload = Buffer.from(JSON.stringify(body), "utf8");
103
+ const sig = sign(null, Buffer.concat([GRANT_DOMAIN, payload]), privateKeyOf("Ed25519", pair));
104
+ return { payload: payload.toString("base64url"), sig: sig.toString("base64url") };
105
+ };
106
+
107
+ export type GrantVerdict =
108
+ | { readonly ok: true; readonly grant: KeyringGrant }
109
+ | { readonly ok: false; readonly code: "bad-grant" | "expired"; readonly reason: string };
110
+
111
+ // Verifies `signed` against the sandbox's pinned signing key at `nowMs`. The keyring checks the nonce, the share and
112
+ // the target against its own facts; this says only whether the sandbox said it, and said it recently.
113
+ export const verifyGrant = (signKey: string, signed: SignedKeyringGrant, nowMs: number): GrantVerdict => {
114
+ let payload: Buffer;
115
+ try {
116
+ payload = Buffer.from(signed.payload, "base64url");
117
+ if (!verify(null, Buffer.concat([GRANT_DOMAIN, payload]), publicKeyOf("Ed25519", signKey), Buffer.from(signed.sig, "base64url"))) {
118
+ return { ok: false, code: "bad-grant", reason: "the grant's signature is not the sandbox's" };
119
+ }
120
+ } catch {
121
+ return { ok: false, code: "bad-grant", reason: "the grant could not be read" };
122
+ }
123
+ let parsed: unknown;
124
+ try {
125
+ parsed = JSON.parse(payload.toString("utf8"));
126
+ } catch {
127
+ return { ok: false, code: "bad-grant", reason: "the grant is not JSON" };
128
+ }
129
+ const grant = KeyringGrantSchema.safeParse(parsed);
130
+ if (!grant.success) {
131
+ return { ok: false, code: "bad-grant", reason: "the grant is not one this keyring knows how to read" };
132
+ }
133
+ const { issuedAt, expiresAt } = grant.data;
134
+ if (expiresAt - issuedAt > KEYRING_GRANT_TTL_MAX_MS || expiresAt <= issuedAt || issuedAt > nowMs + GRANT_SKEW_MS) {
135
+ return { ok: false, code: "bad-grant", reason: "the grant's times are not ones this sandbox would sign" };
136
+ }
137
+ if (expiresAt <= nowMs) {
138
+ return { ok: false, code: "expired", reason: "the grant has expired: the sandbox asks again" };
139
+ }
140
+ return { ok: true, grant: grant.data };
141
+ };
142
+
143
+ // A short, readable fingerprint of a public key, for a person comparing what two screens show.
144
+ export const keyFingerprint = (key: string): string => {
145
+ const raw = Buffer.from(key, "base64url");
146
+ const hex = raw.subarray(0, 8).toString("hex").toUpperCase();
147
+ return hex.replace(/(.{4})(?=.)/gu, "$1-");
148
+ };
@@ -5,6 +5,7 @@
5
5
  export const MESSAGE_LIMITS = {
6
6
  bluesky: 300,
7
7
  discord: 2_000,
8
+ facebook: 63_206,
8
9
  instagram: 2_200,
9
10
  linkedin: 3_000,
10
11
  mastodon: 500,
@@ -12,6 +13,8 @@ export const MESSAGE_LIMITS = {
12
13
  slack: 4_000,
13
14
  // sendMessage refuses anything longer outright.
14
15
  telegram: 4_096,
16
+ // A video's caption; a comment under one stops at 150.
17
+ tiktok: 4_000,
15
18
  // A linked device (what the WhatsApp extension is) sends up to 65,536; the 4,096 often quoted is the Business API's.
16
19
  whatsapp: 65_536,
17
20
  x: 280,