@meshline/sdk 0.0.0-stage → 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -2
  3. package/dist/channels/repository.d.ts +73 -0
  4. package/dist/channels/repository.d.ts.map +1 -0
  5. package/dist/channels/repository.js +367 -0
  6. package/dist/channels/repository.js.map +1 -0
  7. package/dist/client/account-operations.d.ts +41 -0
  8. package/dist/client/account-operations.d.ts.map +1 -0
  9. package/dist/client/account-operations.js +304 -0
  10. package/dist/client/account-operations.js.map +1 -0
  11. package/dist/client/conversations.d.ts +36 -0
  12. package/dist/client/conversations.d.ts.map +1 -0
  13. package/dist/client/conversations.js +144 -0
  14. package/dist/client/conversations.js.map +1 -0
  15. package/dist/client.d.ts +53 -0
  16. package/dist/client.d.ts.map +1 -0
  17. package/dist/client.js +162 -0
  18. package/dist/client.js.map +1 -0
  19. package/dist/components/account.d.ts +38 -0
  20. package/dist/components/account.d.ts.map +1 -0
  21. package/dist/components/account.js +243 -0
  22. package/dist/components/account.js.map +1 -0
  23. package/dist/components/channel.d.ts +78 -0
  24. package/dist/components/channel.d.ts.map +1 -0
  25. package/dist/components/channel.js +475 -0
  26. package/dist/components/channel.js.map +1 -0
  27. package/dist/components/component.d.ts +49 -0
  28. package/dist/components/component.d.ts.map +1 -0
  29. package/dist/components/component.js +194 -0
  30. package/dist/components/component.js.map +1 -0
  31. package/dist/components/device.d.ts +60 -0
  32. package/dist/components/device.d.ts.map +1 -0
  33. package/dist/components/device.js +648 -0
  34. package/dist/components/device.js.map +1 -0
  35. package/dist/components/group.d.ts +141 -0
  36. package/dist/components/group.d.ts.map +1 -0
  37. package/dist/components/group.js +933 -0
  38. package/dist/components/group.js.map +1 -0
  39. package/dist/components/message.d.ts +63 -0
  40. package/dist/components/message.d.ts.map +1 -0
  41. package/dist/components/message.js +439 -0
  42. package/dist/components/message.js.map +1 -0
  43. package/dist/components/profile.d.ts +37 -0
  44. package/dist/components/profile.d.ts.map +1 -0
  45. package/dist/components/profile.js +232 -0
  46. package/dist/components/profile.js.map +1 -0
  47. package/dist/crypto/groups.d.ts +56 -0
  48. package/dist/crypto/groups.d.ts.map +1 -0
  49. package/dist/crypto/groups.js +163 -0
  50. package/dist/crypto/groups.js.map +1 -0
  51. package/dist/crypto/messages.d.ts +52 -0
  52. package/dist/crypto/messages.d.ts.map +1 -0
  53. package/dist/crypto/messages.js +180 -0
  54. package/dist/crypto/messages.js.map +1 -0
  55. package/dist/crypto/primitives.d.ts +30 -0
  56. package/dist/crypto/primitives.d.ts.map +1 -0
  57. package/dist/crypto/primitives.js +102 -0
  58. package/dist/crypto/primitives.js.map +1 -0
  59. package/dist/errors.d.ts +11 -0
  60. package/dist/errors.d.ts.map +1 -0
  61. package/dist/errors.js +14 -0
  62. package/dist/errors.js.map +1 -0
  63. package/dist/groups/account-sync.d.ts +28 -0
  64. package/dist/groups/account-sync.d.ts.map +1 -0
  65. package/dist/groups/account-sync.js +235 -0
  66. package/dist/groups/account-sync.js.map +1 -0
  67. package/dist/groups/messages.d.ts +39 -0
  68. package/dist/groups/messages.d.ts.map +1 -0
  69. package/dist/groups/messages.js +141 -0
  70. package/dist/groups/messages.js.map +1 -0
  71. package/dist/groups/operations.d.ts +57 -0
  72. package/dist/groups/operations.d.ts.map +1 -0
  73. package/dist/groups/operations.js +225 -0
  74. package/dist/groups/operations.js.map +1 -0
  75. package/dist/groups/repository.d.ts +56 -0
  76. package/dist/groups/repository.d.ts.map +1 -0
  77. package/dist/groups/repository.js +254 -0
  78. package/dist/groups/repository.js.map +1 -0
  79. package/dist/groups/secrets.d.ts +67 -0
  80. package/dist/groups/secrets.d.ts.map +1 -0
  81. package/dist/groups/secrets.js +452 -0
  82. package/dist/groups/secrets.js.map +1 -0
  83. package/dist/groups/state.d.ts +39 -0
  84. package/dist/groups/state.d.ts.map +1 -0
  85. package/dist/groups/state.js +201 -0
  86. package/dist/groups/state.js.map +1 -0
  87. package/dist/identity/identifiers.d.ts +9 -0
  88. package/dist/identity/identifiers.d.ts.map +1 -0
  89. package/dist/identity/identifiers.js +37 -0
  90. package/dist/identity/identifiers.js.map +1 -0
  91. package/dist/identity/neo.d.ts +11 -0
  92. package/dist/identity/neo.d.ts.map +1 -0
  93. package/dist/identity/neo.js +77 -0
  94. package/dist/identity/neo.js.map +1 -0
  95. package/dist/index.d.ts +53 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +46 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/interactions.d.ts +31 -0
  100. package/dist/interactions.d.ts.map +1 -0
  101. package/dist/interactions.js +2 -0
  102. package/dist/interactions.js.map +1 -0
  103. package/dist/messages/account-sender.d.ts +9 -0
  104. package/dist/messages/account-sender.d.ts.map +1 -0
  105. package/dist/messages/account-sender.js +46 -0
  106. package/dist/messages/account-sender.js.map +1 -0
  107. package/dist/messages/contacts.d.ts +103 -0
  108. package/dist/messages/contacts.d.ts.map +1 -0
  109. package/dist/messages/contacts.js +525 -0
  110. package/dist/messages/contacts.js.map +1 -0
  111. package/dist/messages/outbox.d.ts +66 -0
  112. package/dist/messages/outbox.d.ts.map +1 -0
  113. package/dist/messages/outbox.js +167 -0
  114. package/dist/messages/outbox.js.map +1 -0
  115. package/dist/messages/receiver.d.ts +35 -0
  116. package/dist/messages/receiver.d.ts.map +1 -0
  117. package/dist/messages/receiver.js +113 -0
  118. package/dist/messages/receiver.js.map +1 -0
  119. package/dist/messages/repository.d.ts +89 -0
  120. package/dist/messages/repository.d.ts.map +1 -0
  121. package/dist/messages/repository.js +295 -0
  122. package/dist/messages/repository.js.map +1 -0
  123. package/dist/models/authentication.d.ts +37 -0
  124. package/dist/models/authentication.d.ts.map +1 -0
  125. package/dist/models/authentication.js +53 -0
  126. package/dist/models/authentication.js.map +1 -0
  127. package/dist/models/channels.d.ts +141 -0
  128. package/dist/models/channels.d.ts.map +1 -0
  129. package/dist/models/channels.js +258 -0
  130. package/dist/models/channels.js.map +1 -0
  131. package/dist/models/contacts.d.ts +68 -0
  132. package/dist/models/contacts.d.ts.map +1 -0
  133. package/dist/models/contacts.js +186 -0
  134. package/dist/models/contacts.js.map +1 -0
  135. package/dist/models/content.d.ts +37 -0
  136. package/dist/models/content.d.ts.map +1 -0
  137. package/dist/models/content.js +137 -0
  138. package/dist/models/content.js.map +1 -0
  139. package/dist/models/device-state.d.ts +8 -0
  140. package/dist/models/device-state.d.ts.map +1 -0
  141. package/dist/models/device-state.js +22 -0
  142. package/dist/models/device-state.js.map +1 -0
  143. package/dist/models/group-admission.d.ts +111 -0
  144. package/dist/models/group-admission.d.ts.map +1 -0
  145. package/dist/models/group-admission.js +153 -0
  146. package/dist/models/group-admission.js.map +1 -0
  147. package/dist/models/group-keys.d.ts +30 -0
  148. package/dist/models/group-keys.d.ts.map +1 -0
  149. package/dist/models/group-keys.js +52 -0
  150. package/dist/models/group-keys.js.map +1 -0
  151. package/dist/models/group-management.d.ts +190 -0
  152. package/dist/models/group-management.d.ts.map +1 -0
  153. package/dist/models/group-management.js +263 -0
  154. package/dist/models/group-management.js.map +1 -0
  155. package/dist/models/groups.d.ts +81 -0
  156. package/dist/models/groups.d.ts.map +1 -0
  157. package/dist/models/groups.js +127 -0
  158. package/dist/models/groups.js.map +1 -0
  159. package/dist/models/identity.d.ts +47 -0
  160. package/dist/models/identity.d.ts.map +1 -0
  161. package/dist/models/identity.js +136 -0
  162. package/dist/models/identity.js.map +1 -0
  163. package/dist/models/messages.d.ts +104 -0
  164. package/dist/models/messages.d.ts.map +1 -0
  165. package/dist/models/messages.js +168 -0
  166. package/dist/models/messages.js.map +1 -0
  167. package/dist/models/profile.d.ts +24 -0
  168. package/dist/models/profile.d.ts.map +1 -0
  169. package/dist/models/profile.js +47 -0
  170. package/dist/models/profile.js.map +1 -0
  171. package/dist/models/relay.d.ts +39 -0
  172. package/dist/models/relay.d.ts.map +1 -0
  173. package/dist/models/relay.js +160 -0
  174. package/dist/models/relay.js.map +1 -0
  175. package/dist/protocol/codec.d.ts +36 -0
  176. package/dist/protocol/codec.d.ts.map +1 -0
  177. package/dist/protocol/codec.js +144 -0
  178. package/dist/protocol/codec.js.map +1 -0
  179. package/dist/protocol/context.d.ts +14 -0
  180. package/dist/protocol/context.d.ts.map +1 -0
  181. package/dist/protocol/context.js +43 -0
  182. package/dist/protocol/context.js.map +1 -0
  183. package/dist/protocol/encoding.d.ts +15 -0
  184. package/dist/protocol/encoding.d.ts.map +1 -0
  185. package/dist/protocol/encoding.js +133 -0
  186. package/dist/protocol/encoding.js.map +1 -0
  187. package/dist/protocol/json.d.ts +16 -0
  188. package/dist/protocol/json.d.ts.map +1 -0
  189. package/dist/protocol/json.js +172 -0
  190. package/dist/protocol/json.js.map +1 -0
  191. package/dist/protocol/validation.d.ts +4 -0
  192. package/dist/protocol/validation.d.ts.map +1 -0
  193. package/dist/protocol/validation.js +18 -0
  194. package/dist/protocol/validation.js.map +1 -0
  195. package/dist/runtime/abort.d.ts +6 -0
  196. package/dist/runtime/abort.d.ts.map +1 -0
  197. package/dist/runtime/abort.js +32 -0
  198. package/dist/runtime/abort.js.map +1 -0
  199. package/dist/runtime/async-gate.d.ts +6 -0
  200. package/dist/runtime/async-gate.d.ts.map +1 -0
  201. package/dist/runtime/async-gate.js +15 -0
  202. package/dist/runtime/async-gate.js.map +1 -0
  203. package/dist/runtime/async-pulse.d.ts +7 -0
  204. package/dist/runtime/async-pulse.d.ts.map +1 -0
  205. package/dist/runtime/async-pulse.js +23 -0
  206. package/dist/runtime/async-pulse.js.map +1 -0
  207. package/dist/runtime/clock.d.ts +16 -0
  208. package/dist/runtime/clock.d.ts.map +1 -0
  209. package/dist/runtime/clock.js +62 -0
  210. package/dist/runtime/clock.js.map +1 -0
  211. package/dist/runtime/events.d.ts +11 -0
  212. package/dist/runtime/events.d.ts.map +1 -0
  213. package/dist/runtime/events.js +45 -0
  214. package/dist/runtime/events.js.map +1 -0
  215. package/dist/runtime/url.d.ts +3 -0
  216. package/dist/runtime/url.d.ts.map +1 -0
  217. package/dist/runtime/url.js +23 -0
  218. package/dist/runtime/url.js.map +1 -0
  219. package/dist/storage/signed-request.d.ts +14 -0
  220. package/dist/storage/signed-request.d.ts.map +1 -0
  221. package/dist/storage/signed-request.js +26 -0
  222. package/dist/storage/signed-request.js.map +1 -0
  223. package/dist/storage/store.d.ts +58 -0
  224. package/dist/storage/store.d.ts.map +1 -0
  225. package/dist/storage/store.js +52 -0
  226. package/dist/storage/store.js.map +1 -0
  227. package/dist/storage/transaction.d.ts +7 -0
  228. package/dist/storage/transaction.d.ts.map +1 -0
  229. package/dist/storage/transaction.js +22 -0
  230. package/dist/storage/transaction.js.map +1 -0
  231. package/dist/transport/client.d.ts +61 -0
  232. package/dist/transport/client.d.ts.map +1 -0
  233. package/dist/transport/client.js +392 -0
  234. package/dist/transport/client.js.map +1 -0
  235. package/dist/transport/discovery.d.ts +19 -0
  236. package/dist/transport/discovery.d.ts.map +1 -0
  237. package/dist/transport/discovery.js +75 -0
  238. package/dist/transport/discovery.js.map +1 -0
  239. package/dist/transport/endpoint.d.ts +6 -0
  240. package/dist/transport/endpoint.d.ts.map +1 -0
  241. package/dist/transport/endpoint.js +35 -0
  242. package/dist/transport/endpoint.js.map +1 -0
  243. package/dist/transport/http.d.ts +52 -0
  244. package/dist/transport/http.d.ts.map +1 -0
  245. package/dist/transport/http.js +128 -0
  246. package/dist/transport/http.js.map +1 -0
  247. package/dist/transport/pool.d.ts +31 -0
  248. package/dist/transport/pool.d.ts.map +1 -0
  249. package/dist/transport/pool.js +137 -0
  250. package/dist/transport/pool.js.map +1 -0
  251. package/dist/transport/relay-error.d.ts +23 -0
  252. package/dist/transport/relay-error.d.ts.map +1 -0
  253. package/dist/transport/relay-error.js +47 -0
  254. package/dist/transport/relay-error.js.map +1 -0
  255. package/dist/transport/rpc.d.ts +31 -0
  256. package/dist/transport/rpc.d.ts.map +1 -0
  257. package/dist/transport/rpc.js +56 -0
  258. package/dist/transport/rpc.js.map +1 -0
  259. package/dist/transport/session.d.ts +49 -0
  260. package/dist/transport/session.d.ts.map +1 -0
  261. package/dist/transport/session.js +169 -0
  262. package/dist/transport/session.js.map +1 -0
  263. package/dist/transport/socket.d.ts +55 -0
  264. package/dist/transport/socket.d.ts.map +1 -0
  265. package/dist/transport/socket.js +266 -0
  266. package/dist/transport/socket.js.map +1 -0
  267. package/dist/transport/subscription.d.ts +36 -0
  268. package/dist/transport/subscription.d.ts.map +1 -0
  269. package/dist/transport/subscription.js +169 -0
  270. package/dist/transport/subscription.js.map +1 -0
  271. package/package.json +35 -4
  272. package/src/channels/repository.ts +244 -0
  273. package/src/client/account-operations.ts +183 -0
  274. package/src/client/conversations.ts +95 -0
  275. package/src/client.ts +108 -0
  276. package/src/components/account.ts +206 -0
  277. package/src/components/channel.ts +253 -0
  278. package/src/components/component.ts +151 -0
  279. package/src/components/device.ts +455 -0
  280. package/src/components/group.ts +546 -0
  281. package/src/components/message.ts +253 -0
  282. package/src/components/profile.ts +179 -0
  283. package/src/crypto/groups.ts +107 -0
  284. package/src/crypto/messages.ts +129 -0
  285. package/src/crypto/primitives.ts +103 -0
  286. package/src/errors.ts +13 -0
  287. package/src/groups/account-sync.ts +144 -0
  288. package/src/groups/messages.ts +89 -0
  289. package/src/groups/operations.ts +145 -0
  290. package/src/groups/repository.ts +161 -0
  291. package/src/groups/secrets.ts +256 -0
  292. package/src/groups/state.ts +138 -0
  293. package/src/identity/identifiers.ts +41 -0
  294. package/src/identity/neo.ts +72 -0
  295. package/src/index.ts +52 -0
  296. package/src/interactions.ts +35 -0
  297. package/src/messages/account-sender.ts +33 -0
  298. package/src/messages/contacts.ts +379 -0
  299. package/src/messages/outbox.ts +152 -0
  300. package/src/messages/receiver.ts +86 -0
  301. package/src/messages/repository.ts +230 -0
  302. package/src/models/authentication.ts +63 -0
  303. package/src/models/channels.ts +155 -0
  304. package/src/models/contacts.ts +141 -0
  305. package/src/models/content.ts +105 -0
  306. package/src/models/device-state.ts +18 -0
  307. package/src/models/group-admission.ts +110 -0
  308. package/src/models/group-keys.ts +34 -0
  309. package/src/models/group-management.ts +182 -0
  310. package/src/models/groups.ts +100 -0
  311. package/src/models/identity.ts +164 -0
  312. package/src/models/messages.ts +139 -0
  313. package/src/models/profile.ts +46 -0
  314. package/src/models/relay.ts +151 -0
  315. package/src/protocol/codec.ts +144 -0
  316. package/src/protocol/context.ts +39 -0
  317. package/src/protocol/encoding.ts +106 -0
  318. package/src/protocol/json.ts +140 -0
  319. package/src/protocol/validation.ts +13 -0
  320. package/src/runtime/abort.ts +27 -0
  321. package/src/runtime/async-gate.ts +16 -0
  322. package/src/runtime/async-pulse.ts +20 -0
  323. package/src/runtime/clock.ts +63 -0
  324. package/src/runtime/events.ts +31 -0
  325. package/src/runtime/url.ts +17 -0
  326. package/src/storage/signed-request.ts +26 -0
  327. package/src/storage/store.ts +90 -0
  328. package/src/storage/transaction.ts +17 -0
  329. package/src/transport/client.ts +328 -0
  330. package/src/transport/discovery.ts +64 -0
  331. package/src/transport/endpoint.ts +32 -0
  332. package/src/transport/http.ts +129 -0
  333. package/src/transport/pool.ts +115 -0
  334. package/src/transport/relay-error.ts +46 -0
  335. package/src/transport/rpc.ts +56 -0
  336. package/src/transport/session.ts +154 -0
  337. package/src/transport/socket.ts +248 -0
  338. package/src/transport/subscription.ts +117 -0
@@ -0,0 +1,379 @@
1
+ import { ProtocolError, StateConflictError } from '../errors.js';
2
+ import { validateIdentifier } from '../identity/identifiers.js';
3
+ import { validateAccountId } from '../identity/neo.js';
4
+ import type { DeviceSigner } from '../interactions.js';
5
+ import { accountContactSyncCodec, contactConsentCodec, contactGrantCodec, contactGrantInput, contactInviteCodec, contactInviteInput, contactRecordCodec,
6
+ filterContactGrantSignatures, selectContactGrant, validateAccountContactSync, validateContactConsent, validateContactInvite, validateContactRecord, verifyContactGrant,
7
+ type AccountContactSync, type ContactAuthorization, type ContactConsent, type ContactGrant, type ContactInvite, type ContactRecord } from '../models/contacts.js';
8
+ import { accountDeviceStateCodec, authorizedDevice, certificateId, validateDeviceState, type AccountDeviceState } from '../models/identity.js';
9
+ import { deviceStateChangedCodec, validateDeviceStateChanged, type MessageTimelineEntry } from '../models/messages.js';
10
+ import type { NetworkContext } from '../protocol/context.js';
11
+ import { encodeUtf8 } from '../protocol/encoding.js';
12
+ import { canonicalJson, requireSafeInteger, type JsonObject } from '../protocol/json.js';
13
+ import type { RuntimeClock } from '../runtime/clock.js';
14
+ import type { MeshlineStore, QueryReader, RecordKey, StoreMutation, StoreSnapshot } from '../storage/store.js';
15
+ import { updateStore } from '../storage/transaction.js';
16
+ import { decodeOutbox, encodeOutbox, outboxKey, type MessageSendState, type OutboxEffects, type OutboxRecord } from './outbox.js';
17
+ import { snapshotReader, type MessageEffects, type RecordGuard } from './repository.js';
18
+
19
+ export type ContactGrantState = 'unknown' | 'missing' | 'valid' | 'expired' | 'insufficientSignatures';
20
+ export interface ContactInfo { readonly accountId: string; readonly alias?: string; readonly state: 'active'; readonly grantFromContact: ContactGrantState; readonly grantToContact: ContactGrantState; readonly updatedAt: number }
21
+ export type ContactRequestDirection = 'incoming' | 'outgoing';
22
+ export interface ContactRequestInfo { readonly accountId: string; readonly direction: ContactRequestDirection; readonly createdAt: number; readonly note?: string; readonly messageId: string; readonly sendState?: MessageSendState }
23
+ export type ContactChangeKind = 'relationship' | 'alias' | 'authorization' | 'deleted';
24
+ export interface ContactChange { readonly accountId: string; readonly contact?: ContactInfo; readonly kinds: readonly ContactChangeKind[] }
25
+ export type ContactRequestChangeKind = 'added' | 'updated' | 'removed';
26
+ export interface ContactRequestChange { readonly accountId: string; readonly direction: ContactRequestDirection; readonly request?: ContactRequestInfo; readonly kind: ContactRequestChangeKind }
27
+ export interface ContactChanges { readonly contacts: readonly ContactChange[]; readonly requests: readonly ContactRequestChange[] }
28
+ export interface StoredContact { readonly record: ContactRecord; readonly confirmedGrantTo?: ContactGrant; readonly requiredDeviceRevision?: number }
29
+ export interface StoredContactRequest extends ContactRequestInfo { readonly consent: ContactConsent }
30
+ export interface PreparedContactSend { readonly outbox: OutboxRecord; readonly guards: readonly RecordGuard[] }
31
+ export interface ContactHost {
32
+ readonly store: MeshlineStore; readonly context: NetworkContext; readonly accountId: string; readonly device: DeviceSigner; readonly clock: RuntimeClock;
33
+ getState(target: string | ContactAuthorization, signal?: AbortSignal): Promise<AccountDeviceState>;
34
+ prepareSend(recipient: string, payload: JsonObject, authorization: ContactAuthorization | undefined, recipientDevices: readonly string[] | undefined, signal?: AbortSignal): Promise<PreparedContactSend>;
35
+ changed(changes: ContactChanges): void;
36
+ }
37
+ export const contactKey = (account: string): RecordKey => ({ collection: 'contacts', key: account });
38
+ export const contactRequestKey = (account: string, direction: ContactRequestDirection): RecordKey => ({ collection: 'contact_requests', key: `${account}|${direction}` });
39
+ export const cachedDeviceStateKey = (account: string): RecordKey => ({ collection: 'device_states', key: account });
40
+ const fingerprint = (value: JsonObject | undefined): string => canonicalJson(value ?? null);
41
+ const recordId = (key: RecordKey): string => `${key.collection}|${key.key}`;
42
+ const put = (key: RecordKey, value: JsonObject): StoreMutation => ({ kind: 'put', ...key, value });
43
+ const removeRequests = (account: string): StoreMutation[] => ['incoming', 'outgoing'].map(direction => ({ kind: 'delete', ...contactRequestKey(account, direction as ContactRequestDirection) }));
44
+ const sends = new Set<MessageSendState>(['queued', 'submitting', 'submissionUnknown', 'relayAccepted', 'targetAccepted', 'failed', 'canceled']);
45
+ export function readContact(value: JsonObject | undefined): StoredContact | undefined {
46
+ if (!value) return undefined;
47
+ const record = contactRecordCodec.decode(value.record!);
48
+ if (value.requiredDeviceRevision !== undefined) requireSafeInteger(value.requiredDeviceRevision, 0);
49
+ return { record, ...(value.confirmedGrantTo === undefined ? {} : { confirmedGrantTo: contactGrantCodec.decode(value.confirmedGrantTo) }),
50
+ ...(value.requiredDeviceRevision === undefined ? {} : { requiredDeviceRevision: value.requiredDeviceRevision }) };
51
+ }
52
+ export function encodeContact(value: StoredContact): JsonObject {
53
+ return { record: contactRecordCodec.encode(value.record), ...(value.confirmedGrantTo ? { confirmedGrantTo: contactGrantCodec.encode(value.confirmedGrantTo) } : {}),
54
+ ...(value.requiredDeviceRevision === undefined ? {} : { requiredDeviceRevision: value.requiredDeviceRevision }) };
55
+ }
56
+ export function readContactRequest(value: JsonObject | undefined): StoredContactRequest | undefined {
57
+ if (!value) return undefined;
58
+ if (typeof value.accountId !== 'string' || (value.direction !== 'incoming' && value.direction !== 'outgoing') || typeof value.messageId !== 'string'
59
+ || value.sendState !== undefined && (typeof value.sendState !== 'string' || !sends.has(value.sendState as MessageSendState))) throw new ProtocolError('invalid_storage', 'Invalid stored contact request.');
60
+ validateAccountId(value.accountId); validateIdentifier('message', value.messageId); requireSafeInteger(value.createdAt, 0);
61
+ const consent = contactConsentCodec.decode(value.consent!);
62
+ return { accountId: value.accountId, direction: value.direction, createdAt: value.createdAt, messageId: value.messageId, consent,
63
+ ...(consent.note === undefined ? {} : { note: consent.note }), ...(value.sendState === undefined ? {} : { sendState: value.sendState as MessageSendState }) };
64
+ }
65
+ export function encodeContactRequest(value: StoredContactRequest): JsonObject {
66
+ return { accountId: value.accountId, direction: value.direction, createdAt: value.createdAt, messageId: value.messageId, consent: contactConsentCodec.encode(value.consent),
67
+ ...(value.sendState === undefined ? {} : { sendState: value.sendState }) };
68
+ }
69
+ export function requestInfo(value: StoredContactRequest): ContactRequestInfo { const { consent: _consent, ...info } = value; return info; }
70
+ export class ContactMessageRejection extends ProtocolError {}
71
+ function reject(code: string, message: string): never { throw new ContactMessageRejection(code, message); }
72
+ function pureValidation<T>(action: () => T): T {
73
+ try { return action(); } catch (error) { if (error instanceof ProtocolError && !(error instanceof ContactMessageRejection)) throw new ContactMessageRejection(error.code, error.message, { cause: error }); throw error; }
74
+ }
75
+
76
+ /** Contact workflows prepare signatures/encrypted synchronization outside the eventual atomic transaction. */
77
+ export class MessageContacts {
78
+ constructor(readonly host: ContactHost) {}
79
+ #checkAccount(account: string): void { validateAccountId(account); if (account === this.host.accountId) throw new ProtocolError('invalid_contact', 'An account cannot add itself as a contact.'); }
80
+ #nextTime(previous = -1): number { const next = Math.max(this.host.clock.nowSeconds(), previous + 1); requireSafeInteger(next, 0); return next; }
81
+ async #snapshot(keys: readonly RecordKey[], signal?: AbortSignal): Promise<readonly RecordGuard[]> {
82
+ const snapshot = await this.host.store.read(keys, signal); return keys.map((query, index) => ({ query, value: snapshot.sets[index]![0]?.value }));
83
+ }
84
+ #effect(guards: readonly RecordGuard[], mutations: readonly StoreMutation[], prepared: readonly PreparedContactSend[] = [], kinds: readonly ContactChangeKind[] = ['relationship', 'authorization']): MessageEffects {
85
+ const all = [...guards, ...prepared.flatMap(value => [...value.guards, { query: outboxKey(value.outbox.request.envelope.messageId), value: undefined }])];
86
+ const expected = new Map<string, RecordGuard>();
87
+ for (const guard of all) { const id = `${guard.query.collection}|${guard.query.key}`; const previous = expected.get(id);
88
+ if (previous && fingerprint(previous.value) !== fingerprint(guard.value)) throw new StateConflictError('Contact preparation used inconsistent snapshots.'); expected.set(id, guard); }
89
+ const unique = [...expected.values()];
90
+ const queries = new Map(unique.map(value => [recordId(value.query), value.query]));
91
+ for (const mutation of mutations) { queries.set(recordId(mutation), { collection: mutation.collection, key: mutation.key });
92
+ if (mutation.collection === 'contacts') { const key = cachedDeviceStateKey(mutation.key); queries.set(recordId(key), key); } }
93
+ const own = cachedDeviceStateKey(this.host.accountId); queries.set(recordId(own), own);
94
+ return { queries: [...queries.values()], committed: (snapshot, changes) => this.host.changed(this.#changes(snapshot, changes, kinds)), plan: snapshot => {
95
+ for (let index = 0; index < unique.length; index++) if (fingerprint(snapshot.sets[index]![0]?.value) !== fingerprint(unique[index]!.value)) throw new StateConflictError('Contact state changed while preparing signed messages.');
96
+ return [...mutations, ...prepared.map(value => put(outboxKey(value.outbox.request.envelope.messageId), encodeOutbox(value.outbox)))];
97
+ } };
98
+ }
99
+ async #commit(effect: MessageEffects, signal?: AbortSignal): Promise<StoreSnapshot> {
100
+ const result = await updateStore(this.host.store, effect.queries, snapshot => { const mutations = effect.plan(snapshot); return { mutations, result: { snapshot, mutations } }; }, signal);
101
+ effect.committed?.(result.snapshot, result.mutations);
102
+ return result.snapshot;
103
+ }
104
+ #changes(snapshot: StoreSnapshot, mutations: readonly StoreMutation[], kinds: readonly ContactChangeKind[]): ContactChanges {
105
+ const before = new Map(snapshot.sets.flat().map(row => [recordId(row), row.value])); const after = new Map(before);
106
+ for (const mutation of mutations) { if (mutation.kind === 'put') after.set(recordId(mutation), mutation.value); else after.delete(recordId(mutation)); }
107
+ const contacts: ContactChange[] = []; const requests: ContactRequestChange[] = []; const visited = new Set<string>();
108
+ for (const mutation of mutations) {
109
+ const id = recordId(mutation); if (visited.has(id) || fingerprint(before.get(id)) === fingerprint(after.get(id))) continue; visited.add(id);
110
+ if (mutation.collection === 'contacts' && kinds.length) {
111
+ const contact = readContact(after.get(id));
112
+ contacts.push({ accountId: mutation.key, kinds: contact?.record.status === 'active' ? [...kinds] : ['deleted'],
113
+ ...(contact?.record.status === 'active' ? { contact: this.#info(contact, state(after.get(recordId(cachedDeviceStateKey(this.host.accountId)))), state(after.get(recordId(cachedDeviceStateKey(mutation.key))))) } : {}) });
114
+ }
115
+ if (mutation.collection === 'contact_requests') {
116
+ const previous = readContactRequest(before.get(id)); const current = readContactRequest(after.get(id)); const request = current ?? previous;
117
+ if (request) requests.push({ accountId: request.accountId, direction: request.direction,
118
+ kind: !current ? 'removed' : !previous || current.direction === 'outgoing' && current.messageId !== previous.messageId ? 'added' : 'updated',
119
+ ...(current ? { request: requestInfo(current) } : {}) });
120
+ }
121
+ }
122
+ return { contacts, requests };
123
+ }
124
+ #protocolRecord(contact: StoredContact): ContactRecord {
125
+ const now = this.host.clock.nowSeconds(); const { grantFromContact, grantToContact, ...record } = contact.record;
126
+ return { ...record, ...(grantFromContact && (grantFromContact.expiresAt ?? Infinity) > now ? { grantFromContact } : {}),
127
+ ...(grantToContact && (grantToContact.expiresAt ?? Infinity) > now ? { grantToContact } : {}) };
128
+ }
129
+ async #sync(contact: StoredContact, signal?: AbortSignal): Promise<PreparedContactSend> {
130
+ return this.host.prepareSend(this.host.accountId, accountContactSyncCodec.encode({ records: [this.#protocolRecord(contact)] }), undefined, undefined, signal);
131
+ }
132
+ #grantState(grant: ContactGrant | undefined, state: AccountDeviceState | undefined): ContactGrantState {
133
+ if (!grant) return 'missing'; if ((grant.expiresAt ?? Infinity) <= this.host.clock.nowSeconds()) return 'expired'; if (!state) return 'unknown';
134
+ return Object.keys(filterContactGrantSignatures(grant, state, this.host.context, this.host.clock.nowSeconds()).signatures).length ? 'valid' : 'insufficientSignatures';
135
+ }
136
+ #info(contact: StoredContact, own?: AccountDeviceState, remote?: AccountDeviceState): ContactInfo {
137
+ const record = contact.record;
138
+ return { accountId: record.account, ...(record.alias === undefined ? {} : { alias: record.alias }), state: 'active', updatedAt: record.updatedAt,
139
+ grantFromContact: this.#grantState(record.grantFromContact, remote), grantToContact: this.#grantState(record.grantToContact, own) };
140
+ }
141
+ async get(account: string, signal?: AbortSignal): Promise<ContactInfo | undefined> {
142
+ this.#checkAccount(account); const rows = await this.host.store.read([contactKey(account), cachedDeviceStateKey(this.host.accountId), cachedDeviceStateKey(account)], signal);
143
+ const contact = readContact(rows.sets[0]![0]?.value); if (!contact || contact.record.status !== 'active') return undefined;
144
+ return this.#info(contact, state(rows.sets[1]![0]?.value), state(rows.sets[2]![0]?.value));
145
+ }
146
+ async list(search = '', signal?: AbortSignal): Promise<QueryReader<ContactInfo>> {
147
+ const rows = await this.host.store.read([{ collection: 'contacts' }, { collection: 'device_states' }], signal);
148
+ const states = new Map(rows.sets[1]!.map(row => [row.key, state(row.value)!]));
149
+ return snapshotReader(rows.sets[0]!.map(row => readContact(row.value)!).filter(value => value.record.status === 'active' && (!search || value.record.account.includes(search) || value.record.alias?.includes(search)))
150
+ .map(value => this.#info(value, states.get(this.host.accountId), states.get(value.record.account))));
151
+ }
152
+ async requests(account?: string, direction?: ContactRequestDirection, signal?: AbortSignal): Promise<QueryReader<ContactRequestInfo>> {
153
+ if (account !== undefined) this.#checkAccount(account); if (direction !== undefined && direction !== 'incoming' && direction !== 'outgoing') throw new TypeError('Unknown contact request direction.');
154
+ const rows = await this.host.store.read([{ collection: 'contact_requests' }], signal);
155
+ const values = rows.sets[0]!.map(row => readContactRequest(row.value)!).filter(value => (account === undefined || value.accountId === account) && (direction === undefined || value.direction === direction))
156
+ .sort((a, b) => a.createdAt - b.createdAt || ordinal(a.accountId, b.accountId) || ordinal(a.direction, b.direction));
157
+ return snapshotReader(values.map(requestInfo));
158
+ }
159
+ async invite(expiresAt: number, signal?: AbortSignal): Promise<ContactInvite> {
160
+ requireSafeInteger(expiresAt, 0); const id = certificateId(this.host.device.certificate, this.host.context);
161
+ let invitation: ContactInvite = { inviter: this.host.accountId, signerDeviceId: id, expiresAt, deviceSignature: new Uint8Array(64) };
162
+ validateContactInvite(invitation, this.host.clock.nowSeconds());
163
+ invitation = { ...invitation, deviceSignature: await this.host.device.sign(contactInviteInput(invitation, this.host.context), signal) };
164
+ if (certificateId(this.host.device.certificate, this.host.context) !== id) throw new StateConflictError('Device identity changed while signing invitation.');
165
+ return invitation;
166
+ }
167
+ async #signGrant(grant: ContactGrant, signal?: AbortSignal): Promise<ContactGrant> {
168
+ const id = certificateId(this.host.device.certificate, this.host.context); if (grant.signatures[id]) return grant;
169
+ const signature = await this.host.device.sign(contactGrantInput(grant, this.host.context), signal);
170
+ if (certificateId(this.host.device.certificate, this.host.context) !== id) throw new StateConflictError('Device identity changed while signing contact grant.');
171
+ return { ...grant, signatures: { ...grant.signatures, [id]: signature } };
172
+ }
173
+ async #consent(account: string, note: string | undefined, signal?: AbortSignal): Promise<ContactConsent> {
174
+ const own = await this.host.getState(this.host.accountId, signal); authorizedDevice(own, certificateId(this.host.device.certificate, this.host.context), this.host.context, this.host.clock.nowSeconds());
175
+ const grant = await this.#signGrant({ grantor: this.host.accountId, grantee: account, signatures: {} }, signal);
176
+ const consent = { deviceState: own, grant, ...(note === undefined ? {} : { note }) }; validateContactConsent(consent, this.host.context, this.host.clock.nowSeconds()); return consent;
177
+ }
178
+ async add(target: string | ContactInvite, note?: string, signal?: AbortSignal): Promise<ContactRequestInfo> {
179
+ const invitation = typeof target === 'string' ? undefined : contactInviteCodec.decode(contactInviteCodec.encode(target)); const account = typeof target === 'string' ? target : target.inviter;
180
+ this.#checkAccount(account); await this.host.getState(invitation ?? account, signal);
181
+ const consent = await this.#consent(account, note, signal); const guards = await this.#snapshot([contactKey(account), contactRequestKey(account, 'outgoing')], signal);
182
+ if (readContact(guards[0]!.value)?.record.status === 'active') throw new ProtocolError('contact_exists', 'The account is already a contact.');
183
+ const previous = readContactRequest(guards[1]!.value); if (previous && previous.sendState !== 'failed' && previous.sendState !== 'canceled') return requestInfo(previous);
184
+ const prepared = await this.host.prepareSend(account, contactConsentCodec.encode(consent), invitation, undefined, signal); const envelope = prepared.outbox.request.envelope;
185
+ const request: StoredContactRequest = { accountId: account, direction: 'outgoing', createdAt: envelope.createdAt, messageId: envelope.messageId, consent, sendState: 'queued', ...(note === undefined ? {} : { note }) };
186
+ await this.#commit(this.#effect(guards, [put(contactRequestKey(account, 'outgoing'), encodeContactRequest(request))], [prepared]), signal); return requestInfo(request);
187
+ }
188
+ async accept(account: string, signal?: AbortSignal): Promise<ContactInfo> {
189
+ this.#checkAccount(account); const guards = await this.#snapshot([contactKey(account), contactRequestKey(account, 'incoming'), contactRequestKey(account, 'outgoing')], signal);
190
+ const incoming = readContactRequest(guards[1]!.value); if (!incoming) throw new ProtocolError('contact_request_required', 'There is no incoming request from this account.');
191
+ const remote = await this.host.getState(incoming.consent.grant, signal); const grant = this.#verified(incoming.consent.grant, remote);
192
+ const consent = await this.#consent(account, undefined, signal); const previous = readContact(guards[0]!.value);
193
+ const contact: StoredContact = { record: { account, status: 'active', updatedAt: this.#nextTime(previous?.record.updatedAt), ...(previous?.record.alias === undefined ? {} : { alias: previous.record.alias }), grantFromContact: grant, grantToContact: consent.grant } };
194
+ const outgoing = await this.host.prepareSend(account, contactConsentCodec.encode(consent), grant, undefined, signal); const sync = await this.#sync(contact, signal);
195
+ await this.#commit(this.#effect(guards, [put(contactKey(account), encodeContact(contact)), ...removeRequests(account)], [outgoing, sync]), signal);
196
+ return this.#info(contact, consent.deviceState, remote);
197
+ }
198
+ async dismiss(account: string, signal?: AbortSignal): Promise<void> {
199
+ this.#checkAccount(account); const key = contactRequestKey(account, 'incoming');
200
+ await this.#commit({ queries: [key], plan: snapshot => snapshot.sets[0]!.length ? [{ kind: 'delete', ...key }] : [],
201
+ committed: (snapshot, mutations) => this.host.changed(this.#changes(snapshot, mutations, [])) }, signal);
202
+ }
203
+ edit(account: string, change: { readonly alias: string | null }, signal?: AbortSignal): Promise<ContactInfo>;
204
+ edit(account: string, change: { readonly deleted: true }, signal?: AbortSignal): Promise<void>;
205
+ async edit(account: string, change: { readonly alias?: string | null; readonly deleted?: true }, signal?: AbortSignal): Promise<ContactInfo | void> {
206
+ this.#checkAccount(account);
207
+ const initial = await this.#snapshot([contactKey(account), contactRequestKey(account, 'incoming'), contactRequestKey(account, 'outgoing'), cachedDeviceStateKey(this.host.accountId), cachedDeviceStateKey(account)], signal);
208
+ const guards = initial.slice(0, 3);
209
+ const previous = readContact(guards[0]!.value);
210
+ if (!change.deleted && previous?.record.status !== 'active') throw new ProtocolError('contact_required', 'Alias changes require an active contact.');
211
+ const { alias: oldAlias, grantFromContact, grantToContact, ...base } = previous?.record ?? { account, status: 'deleted' as const, updatedAt: 0 };
212
+ const alias = change.alias === null ? undefined : change.alias ?? oldAlias;
213
+ if (!change.deleted && alias === oldAlias) return this.#info(previous!, state(initial[3]!.value), state(initial[4]!.value));
214
+ const record: ContactRecord = { ...base, status: change.deleted ? 'deleted' : 'active', updatedAt: this.#nextTime(previous?.record.updatedAt), ...(alias === undefined ? {} : { alias }),
215
+ ...(!change.deleted && grantFromContact ? { grantFromContact } : {}), ...(!change.deleted && grantToContact ? { grantToContact } : {}) };
216
+ validateContactRecord(record, this.host.clock.nowSeconds()); const contact: StoredContact = change.deleted ? { record } : { ...previous!, record };
217
+ const sync = await this.#sync(contact, signal);
218
+ const committed = await this.#commit(this.#effect(guards, [put(contactKey(account), encodeContact(contact)), ...(change.deleted ? removeRequests(account) : [])], [sync], [change.deleted ? 'deleted' : 'alias']), signal);
219
+ if (!change.deleted) {
220
+ // Return from the successful transaction's snapshot. A fresh read
221
+ // could race another write or be canceled by a disposal observer.
222
+ const devices = new Map(committed.sets.flat().filter(row => row.collection === 'device_states').map(row => [row.key, state(row.value)]));
223
+ return this.#info(contact, devices.get(this.host.accountId), devices.get(account));
224
+ }
225
+ }
226
+ #verified(grant: ContactGrant, devices: AccountDeviceState): ContactGrant { verifyContactGrant(grant, devices, this.host.context, this.host.clock.nowSeconds()); return filterContactGrantSignatures(grant, devices, this.host.context, this.host.clock.nowSeconds()); }
227
+ #merge(previous: ContactGrant | undefined, incoming: ContactGrant, devices: AccountDeviceState): ContactGrant {
228
+ const result = selectContactGrant(previous, incoming, devices, this.host.context, this.host.clock.nowSeconds());
229
+ if (result.action === 'invalid') throw result.error; if (result.action === 'out_of_scope' || !result.grant) throw new ProtocolError('invalid_authorization', 'Contact grants have inconsistent scope.'); return result.grant;
230
+ }
231
+ /** Called only after envelope signature and decryption pass. External lookups remain outside rejection wrapping. */
232
+ async prepareReception(entry: MessageTimelineEntry, payload: JsonObject, signal?: AbortSignal): Promise<MessageEffects | undefined> {
233
+ const envelope = entry.envelope; const type = payload['$type']; const self = envelope.from === envelope.to;
234
+ if (envelope.from === this.host.accountId && !self) return undefined;
235
+ if (['meshline.account.group.state.request', 'meshline.account.group.state.sync', 'meshline.account.group.history_secret.sync'].includes(type as string)) {
236
+ if (!self || envelope.from !== this.host.accountId) reject('invalid_binding', 'Account group synchronization requires self-delivery.');
237
+ await this.host.getState(this.host.accountId, signal); return this.#authorize(entry);
238
+ }
239
+ if (type === 'meshline.contact.consent') {
240
+ if (self) reject('invalid_binding', 'Contact consent cannot be self-delivered.');
241
+ const consent = pureValidation(() => { const value = contactConsentCodec.decode(payload); validateContactConsent(value, this.host.context, this.host.clock.nowSeconds()); return value; });
242
+ return this.#receiveConsent(entry, consent, signal);
243
+ }
244
+ const contacts = ['meshline.contact.grant', 'meshline.device.state.changed', 'meshline.account.contacts.sync'];
245
+ if (contacts.includes(type as string) && self !== (type === 'meshline.account.contacts.sync')) reject('invalid_binding', 'Contact payload has invalid sender/recipient account binding.');
246
+ if (type === 'meshline.account.contacts.sync') return this.#receiveSync(entry, pureValidation(() => { const value = accountContactSyncCodec.decode(payload); validateAccountContactSync(value, this.host.clock.nowSeconds(), this.host.accountId); return value; }), signal);
247
+ if (type !== 'meshline.message.direct' && type !== 'meshline.contact.grant' && type !== 'meshline.device.state.changed') return undefined;
248
+ await this.host.getState(this.host.accountId, signal);
249
+ if (type === 'meshline.contact.grant') {
250
+ const grant = pureValidation(() => contactGrantCodec.decode(payload));
251
+ if (grant.grantor !== envelope.from || grant.grantee !== envelope.to) reject('invalid_binding', 'Contact grant differs from envelope participants.');
252
+ const remote = await this.host.getState(grant, signal); pureValidation(() => this.#verified(grant, remote));
253
+ const guards = await this.#snapshot([contactKey(envelope.from)], signal); const current = readContact(guards[0]!.value);
254
+ if (!current || current.record.status !== 'active') reject('contact_required', 'No active contact exists for this sender.');
255
+ const merged = pureValidation(() => this.#merge(current.record.grantFromContact, grant, remote));
256
+ if (current.record.grantFromContact && contactGrantCodec.stringify(merged) === contactGrantCodec.stringify(current.record.grantFromContact)) return this.#authorize(entry);
257
+ const next = { ...current, record: { ...current.record, grantFromContact: merged, updatedAt: this.#nextTime(current.record.updatedAt) } };
258
+ const sync = await this.#sync(next, signal);
259
+ return this.#authorize(entry, this.#effect(guards, [put(contactKey(envelope.from), encodeContact(next))], [sync], ['authorization']));
260
+ }
261
+ if (type === 'meshline.device.state.changed') {
262
+ const change = pureValidation(() => { const value = deviceStateChangedCodec.decode(payload); validateDeviceStateChanged(value); return value; });
263
+ const key = contactKey(envelope.from);
264
+ return this.#authorize(entry, { queries: [key, cachedDeviceStateKey(envelope.from)], plan: snapshot => {
265
+ const contact = readContact(snapshot.sets[0]![0]?.value); if (!contact || contact.record.status !== 'active') reject('contact_required', 'Contact is no longer active.');
266
+ const remote = state(snapshot.sets[1]![0]?.value); if (remote && remote.revision >= change.revision) return [];
267
+ return [put(key, encodeContact({ ...contact, requiredDeviceRevision: Math.max(change.revision, contact.requiredDeviceRevision ?? -1) }))];
268
+ } });
269
+ }
270
+ return this.#authorize(entry);
271
+ }
272
+ #authorize(entry: MessageTimelineEntry, effect?: MessageEffects): MessageEffects {
273
+ const self = entry.envelope.from === entry.envelope.to; const account = entry.envelope.from;
274
+ return { queries: [cachedDeviceStateKey(this.host.accountId), contactKey(account), ...effect?.queries ?? []],
275
+ ...(effect?.committed ? { committed: (snapshot: StoreSnapshot, mutations: readonly StoreMutation[]) => effect.committed!({ ...snapshot, sets: snapshot.sets.slice(2) }, mutations) } : {}), plan: snapshot => {
276
+ const own = state(snapshot.sets[0]![0]?.value); if (!own) throw new ProtocolError('device_state_required', 'Current own device state is unavailable.');
277
+ pureValidation(() => {
278
+ if (self) authorizedDevice(own, entry.envelope.fromDeviceId, this.host.context, this.host.clock.nowSeconds());
279
+ else { const contact = readContact(snapshot.sets[1]![0]?.value); if (contact?.record.status !== 'active' || !contact.record.grantToContact) reject('contact_required', 'No active locally issued grant remains for this sender.');
280
+ verifyContactGrant(contact.record.grantToContact, own, this.host.context, this.host.clock.nowSeconds()); }
281
+ });
282
+ return effect?.plan({ ...snapshot, sets: snapshot.sets.slice(2) }) ?? [];
283
+ } };
284
+ }
285
+ async #receiveConsent(entry: MessageTimelineEntry, consent: ContactConsent, signal?: AbortSignal): Promise<MessageEffects> {
286
+ const account = entry.envelope.from;
287
+ pureValidation(() => {
288
+ if (consent.deviceState.account !== account || consent.grant.grantor !== account || consent.grant.grantee !== this.host.accountId) reject('invalid_binding', 'Consent differs from envelope participants.');
289
+ authorizedDevice(consent.deviceState, entry.envelope.fromDeviceId, this.host.context, this.host.clock.nowSeconds());
290
+ if (!this.#verified(consent.grant, consent.deviceState).signatures[entry.envelope.fromDeviceId]) reject('invalid_signature', 'Consent sender did not sign its grant.');
291
+ });
292
+ const guards = [...await this.#snapshot([contactKey(account), contactRequestKey(account, 'incoming'), contactRequestKey(account, 'outgoing'), cachedDeviceStateKey(account)], signal)];
293
+ const previous = readContact(guards[0]!.value); const waiting = readContactRequest(guards[2]!.value); const cached = state(guards[3]!.value);
294
+ const waitingDelivery = waiting ? (await this.host.store.read([outboxKey(waiting.messageId)], signal)).sets[0]![0] : undefined;
295
+ if (waiting) guards.push({ query: outboxKey(waiting.messageId), value: waitingDelivery?.value });
296
+ const accepted = waiting?.sendState === 'targetAccepted' || waitingDelivery !== undefined && decodeOutbox(waitingDelivery).acceptedAt !== undefined;
297
+ if (cached && (cached.revision > consent.deviceState.revision || cached.revision === consent.deviceState.revision && accountDeviceStateCodec.stringify(cached) !== accountDeviceStateCodec.stringify(consent.deviceState))) reject('stale_state', 'Consent contains an older or conflicting device state.');
298
+ const mutations: StoreMutation[] = [put(cachedDeviceStateKey(account), accountDeviceStateCodec.encode(consent.deviceState))];
299
+ if (previous?.record.status === 'active' || waiting && waiting.sendState !== 'canceled') {
300
+ const grant = pureValidation(() => this.#merge(previous?.record.grantFromContact, consent.grant, consent.deviceState));
301
+ const outgoing = waiting?.consent.grant ?? previous?.record.grantToContact;
302
+ const record: ContactRecord = { ...(previous?.record ?? { account, status: 'active' as const, updatedAt: 0 }), status: 'active', updatedAt: this.#nextTime(previous?.record.updatedAt), grantFromContact: grant, ...(outgoing ? { grantToContact: outgoing } : {}) };
303
+ const current: StoredContact = { ...previous, record, ...(waiting && accepted && outgoing ? { confirmedGrantTo: outgoing } : {}) };
304
+ const sync = await this.#sync(current, signal); mutations.push(put(contactKey(account), encodeContact(current)), ...removeRequests(account));
305
+ return this.#effect(guards, mutations, [sync]);
306
+ }
307
+ const request: StoredContactRequest = { accountId: account, direction: 'incoming', messageId: entry.envelope.messageId, createdAt: entry.envelope.createdAt, consent, ...(consent.note === undefined ? {} : { note: consent.note }) };
308
+ mutations.push(put(contactRequestKey(account, 'incoming'), encodeContactRequest(request))); return this.#effect(guards, mutations);
309
+ }
310
+ async #receiveSync(entry: MessageTimelineEntry, sync: AccountContactSync, signal?: AbortSignal): Promise<MessageEffects> {
311
+ const own = await this.host.getState(this.host.accountId, signal); pureValidation(() => authorizedDevice(own, entry.envelope.fromDeviceId, this.host.context, this.host.clock.nowSeconds()));
312
+ const guards = await this.#snapshot((sync.records ?? []).map(value => contactKey(value.account)), signal); const mutations: StoreMutation[] = []; const prepared: PreparedContactSend[] = [];
313
+ for (let index = 0; index < (sync.records?.length ?? 0); index++) {
314
+ const source = sync.records![index]!; const previous = readContact(guards[index]!.value);
315
+ if (previous && previous.record.updatedAt > source.updatedAt) continue;
316
+ let incoming: ContactGrant | undefined; let outgoing: ContactGrant | undefined; let added = false;
317
+ if (source.grantFromContact) { const remote = await this.host.getState(source.grantFromContact, signal); incoming = pureValidation(() => this.#merge(previous?.record.grantFromContact, source.grantFromContact!, remote)); }
318
+ if (source.grantToContact) { const verified = pureValidation(() => this.#verified(source.grantToContact!, own)); const supplemented = await this.#signGrant(verified, signal);
319
+ outgoing = pureValidation(() => this.#merge(previous?.record.grantToContact, supplemented, own)); added = contactGrantCodec.stringify(supplemented) !== contactGrantCodec.stringify(verified); }
320
+ const current: StoredContact = source.status === 'deleted' ? { record: source } : { ...previous, record: { ...source,
321
+ ...(incoming ?? previous?.record.grantFromContact ? { grantFromContact: incoming ?? previous!.record.grantFromContact! } : {}),
322
+ ...(outgoing ?? previous?.record.grantToContact ? { grantToContact: outgoing ?? previous!.record.grantToContact! } : {}), ...(added ? { updatedAt: this.#nextTime(source.updatedAt) } : {}) } };
323
+ mutations.push(put(contactKey(source.account), encodeContact(current))); if (source.status === 'deleted') mutations.push(...removeRequests(source.account));
324
+ if (added) { prepared.push(await this.#sync(current, signal)); if (current.record.grantFromContact && outgoing) prepared.push(await this.host.prepareSend(source.account, contactGrantCodec.encode(outgoing), current.record.grantFromContact, undefined, signal)); }
325
+ }
326
+ if (sync.requestSnapshot) {
327
+ const rows = await this.host.store.read([{ collection: 'contacts' }], signal);
328
+ const records = new Map(rows.sets[0]!.map(row => [row.key, readContact(row.value)!]));
329
+ for (const mutation of mutations) if (mutation.kind === 'put' && mutation.collection === 'contacts') records.set(mutation.key, readContact(mutation.value)!);
330
+ let batch: ContactRecord[] = []; let size = 0;
331
+ const flush = async (): Promise<void> => { if (!batch.length) return; prepared.push(await this.host.prepareSend(this.host.accountId, accountContactSyncCodec.encode({ records: batch }), undefined, [entry.envelope.fromDeviceId], signal)); batch = []; size = 0; };
332
+ for (const record of records.values()) {
333
+ const item = this.#protocolRecord(record); const bytes = encodeUtf8(contactRecordCodec.stringify(item)).length;
334
+ if (bytes > 131072) throw new ProtocolError('invalid_size', 'A contact record exceeds synchronization limits.');
335
+ if (batch.length >= 128 || batch.length && size + bytes > 131072) await flush(); batch.push(item); size += bytes;
336
+ }
337
+ await flush();
338
+ }
339
+ return this.#authorize(entry, this.#effect(guards, mutations, prepared, ['relationship', 'alias', 'authorization']));
340
+ }
341
+ deliveryEffects(grants: ReadonlyMap<string, ContactGrant>): OutboxEffects {
342
+ return { queries: [{ collection: 'contact_requests' }, { collection: 'contacts' }, cachedDeviceStateKey(this.host.accountId)],
343
+ committed: (snapshot, mutations) => { if (mutations.length) this.host.changed(this.#changes(snapshot, mutations, [])); }, plan: (snapshot, change) => {
344
+ const record = change.current; const envelope = record.request.envelope; if (record.isDirect || envelope.to === this.host.accountId) return [];
345
+ const mutations: StoreMutation[] = []; const key = contactRequestKey(envelope.to, 'outgoing');
346
+ const requestRow = snapshot.sets[1]!.find(row => row.key === key.key); const request = readContactRequest(requestRow?.value);
347
+ if (request?.messageId === envelope.messageId) mutations.push(record.state === 'canceled' ? { kind: 'delete', ...key } : put(key, encodeContactRequest({ ...request, sendState: record.state })));
348
+ const contact = readContact(snapshot.sets[2]!.find(row => row.key === envelope.to)?.value); const own = state(snapshot.sets[3]![0]?.value); const grant = grants.get(envelope.messageId);
349
+ if (record.acceptedAt !== undefined && contact?.record.status === 'active' && own && grant)
350
+ mutations.push(put(contactKey(envelope.to), encodeContact({ ...contact, confirmedGrantTo: this.#merge(contact.confirmedGrantTo, grant, own) })));
351
+ return mutations;
352
+ } };
353
+ }
354
+ /** Repairs local grant signatures and acknowledges peer device-state revisions with durable outgoing messages. */
355
+ async maintain(account: string, notifyDeviceRevision: number | undefined, signal?: AbortSignal): Promise<void> {
356
+ const guards = await this.#snapshot([contactKey(account)], signal); const saved = readContact(guards[0]!.value);
357
+ if (!saved || saved.record.status !== 'active') return;
358
+ const now = this.host.clock.nowSeconds(); const own = await this.host.getState(this.host.accountId, signal);
359
+ authorizedDevice(own, certificateId(this.host.device.certificate, this.host.context), this.host.context, now);
360
+ const incoming = saved.record.grantFromContact && (saved.record.grantFromContact.expiresAt ?? Infinity) > now ? saved.record.grantFromContact : undefined;
361
+ let revisionResolved = false;
362
+ if (saved.requiredDeviceRevision !== undefined && incoming) {
363
+ const remote = await this.host.getState(incoming, signal);
364
+ if (remote.revision < saved.requiredDeviceRevision) throw new ProtocolError('stale_device_state', 'Contact device state has not reached its announced revision.'); revisionResolved = true;
365
+ }
366
+ const issued = saved.record.grantToContact; let supplemented: ContactGrant | undefined;
367
+ if (issued && (issued.expiresAt ?? Infinity) > now) supplemented = await this.#signGrant(filterContactGrantSignatures(issued, own, this.host.context, now), signal);
368
+ const changedGrant = supplemented !== undefined && contactGrantCodec.stringify(supplemented) !== contactGrantCodec.stringify(issued!);
369
+ const { requiredDeviceRevision, ...withoutRevision } = saved;
370
+ const current: StoredContact = { ...(revisionResolved ? withoutRevision : saved), record: { ...saved.record, ...(changedGrant ? { grantToContact: supplemented!, updatedAt: this.#nextTime(saved.record.updatedAt) } : {}) } };
371
+ const prepared: PreparedContactSend[] = [];
372
+ if (changedGrant) { prepared.push(await this.#sync(current, signal)); if (incoming) prepared.push(await this.host.prepareSend(account, contactGrantCodec.encode(supplemented!), incoming, undefined, signal)); }
373
+ if (notifyDeviceRevision !== undefined && incoming) prepared.push(await this.host.prepareSend(account, deviceStateChangedCodec.encode({ revision: own.revision }), incoming, undefined, signal));
374
+ const mutations = changedGrant || revisionResolved ? [put(contactKey(account), encodeContact(current))] : [];
375
+ if (mutations.length || prepared.length) await this.#commit(this.#effect(guards, mutations, prepared, ['authorization']), signal);
376
+ }
377
+ }
378
+ function state(value: JsonObject | undefined): AccountDeviceState | undefined { return value && accountDeviceStateCodec.decode(value); }
379
+ function ordinal(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; }
@@ -0,0 +1,152 @@
1
+ import { ProtocolError, StateConflictError } from '../errors.js';
2
+ import { validateIdentifier } from '../identity/identifiers.js';
3
+ import { validateRelayId } from '../identity/neo.js';
4
+ import { messageSendRequestCodec, validateMessageDeliveryStatus, validateMessageSendRequest, type MessageDeliveryStatus, type MessageSendRequest } from '../models/messages.js';
5
+ import { canonicalJson, requireObject, requireSafeInteger, type JsonObject } from '../protocol/json.js';
6
+ import { AsyncGate } from '../runtime/async-gate.js';
7
+ import { throwIfAborted, type RuntimeClock } from '../runtime/clock.js';
8
+ import type { MeshlineStore, RecordKey, RecordQuery, StoredRecord, StoreMutation, StoreSnapshot } from '../storage/store.js';
9
+ import { updateStore } from '../storage/transaction.js';
10
+ import { RelayError } from '../transport/relay-error.js';
11
+
12
+ export type MessageSendState = 'queued' | 'submitting' | 'submissionUnknown' | 'relayAccepted' | 'targetAccepted' | 'failed' | 'canceled';
13
+ export interface MessageSendStatus {
14
+ readonly messageId: string; readonly recipient: string; readonly createdAt: number; readonly state: MessageSendState;
15
+ readonly acceptedRelayId?: string; readonly acceptedAt?: number; readonly errorMessage?: string;
16
+ }
17
+ export interface OutboxRecord {
18
+ readonly request: MessageSendRequest; readonly relayId: string; readonly state: MessageSendState;
19
+ readonly nextAttemptAt: number; readonly isDirect: boolean; readonly acceptedAt?: number; readonly errorMessage?: string;
20
+ }
21
+ export interface OutboxChange { readonly previous: OutboxRecord; readonly current: OutboxRecord }
22
+ const states = new Set<MessageSendState>(['queued', 'submitting', 'submissionUnknown', 'relayAccepted', 'targetAccepted', 'failed', 'canceled']);
23
+ const pending = new Set<MessageSendState>(['queued', 'submissionUnknown', 'relayAccepted']);
24
+ export const outboxKey = (messageId: string): RecordKey => ({ collection: 'message_outbox', key: messageId });
25
+ export function encodeOutbox(value: OutboxRecord): JsonObject {
26
+ return { request: messageSendRequestCodec.encode(value.request), relayId: value.relayId, state: value.state, nextAttemptAt: value.nextAttemptAt, isDirect: value.isDirect,
27
+ ...(value.acceptedAt === undefined ? {} : { acceptedAt: value.acceptedAt }), ...(value.errorMessage === undefined ? {} : { errorMessage: value.errorMessage }) };
28
+ }
29
+ export function decodeOutbox(row: StoredRecord): OutboxRecord {
30
+ const value = row.value; const request = messageSendRequestCodec.decode(requireObject(value.request!)); validateMessageSendRequest(request, 0);
31
+ if (request.envelope.messageId !== row.key || typeof value.relayId !== 'string' || typeof value.state !== 'string' || !states.has(value.state as MessageSendState)
32
+ || typeof value.isDirect !== 'boolean' || value.errorMessage !== undefined && typeof value.errorMessage !== 'string') throw new ProtocolError('invalid_storage', 'Invalid persisted message outbox entry.');
33
+ validateRelayId(value.relayId); requireSafeInteger(value.nextAttemptAt, 0);
34
+ if (value.acceptedAt !== undefined) requireSafeInteger(value.acceptedAt, 0);
35
+ const state = value.state as MessageSendState;
36
+ if (['relayAccepted', 'targetAccepted'].includes(state) && value.acceptedAt === undefined
37
+ || ['queued', 'submitting', 'submissionUnknown', 'canceled'].includes(state) && value.acceptedAt !== undefined)
38
+ throw new ProtocolError('invalid_storage', 'Outbox state conflicts with its acceptance evidence.');
39
+ return { request, relayId: value.relayId, state, nextAttemptAt: value.nextAttemptAt, isDirect: value.isDirect,
40
+ ...(value.acceptedAt === undefined ? {} : { acceptedAt: value.acceptedAt }), ...(value.errorMessage === undefined ? {} : { errorMessage: value.errorMessage as string }) };
41
+ }
42
+ export function outboxStatus(value: OutboxRecord): MessageSendStatus {
43
+ return { messageId: value.request.envelope.messageId, recipient: value.request.envelope.to, createdAt: value.request.envelope.createdAt, state: value.state,
44
+ ...(value.acceptedAt === undefined ? {} : { acceptedAt: value.acceptedAt, acceptedRelayId: value.relayId }), ...(value.errorMessage === undefined ? {} : { errorMessage: value.errorMessage }) };
45
+ }
46
+ /** Resolved before a queued message is claimed; requests themselves must not retry business operations internally. */
47
+ export interface OutboxTransport {
48
+ getHome(signal?: AbortSignal): Promise<string>;
49
+ prepare(relayId: string, signal?: AbortSignal): Promise<{
50
+ send(request: MessageSendRequest, signal?: AbortSignal): Promise<MessageDeliveryStatus>;
51
+ status(messageId: string, signal?: AbortSignal): Promise<MessageDeliveryStatus>;
52
+ }>;
53
+ currentHome(): string | undefined;
54
+ }
55
+ export interface OutboxEffects {
56
+ /** Additional rows needed for atomic contact/grant delivery effects. First snapshot set is always the outbox record. */
57
+ readonly queries: readonly RecordQuery[];
58
+ plan(snapshot: StoreSnapshot, change: OutboxChange): readonly StoreMutation[];
59
+ /** Invoked once after a successful commit, with that transaction's input and exact effects. */
60
+ committed?(snapshot: StoreSnapshot, mutations: readonly StoreMutation[]): void;
61
+ }
62
+ /** Internal durable delivery engine. Its owner supplies lifecycle, authorization, events and enqueue transactions. */
63
+ export class MessageOutbox {
64
+ readonly #gate = new AsyncGate();
65
+ constructor(readonly store: MeshlineStore, readonly clock: RuntimeClock, readonly transport: OutboxTransport, readonly effects?: OutboxEffects) {}
66
+
67
+ async recover(signal?: AbortSignal): Promise<readonly OutboxChange[]> {
68
+ const snapshot = await this.store.read([{ collection: 'message_outbox' }], signal); const changes: OutboxChange[] = [];
69
+ for (const row of snapshot.sets[0]!) {
70
+ if (decodeOutbox(row).state !== 'submitting') continue;
71
+ const change = await this.#update(row.key, current => current.state === 'submitting' ? { ...current, state: 'submissionUnknown', nextAttemptAt: this.clock.nowSeconds() } : undefined, signal);
72
+ if (change) changes.push(change);
73
+ }
74
+ return changes;
75
+ }
76
+ async get(messageId: string, signal?: AbortSignal): Promise<OutboxRecord | undefined> {
77
+ validateIdentifier('message', messageId); const row = (await this.store.read([outboxKey(messageId)], signal)).sets[0]![0]; return row && decodeOutbox(row);
78
+ }
79
+ async cancel(messageId: string, signal?: AbortSignal): Promise<OutboxChange | undefined> {
80
+ validateIdentifier('message', messageId);
81
+ return this.#update(messageId, value => value.state === 'queued' ? { ...value, state: 'canceled', errorMessage: undefined } : undefined, signal);
82
+ }
83
+ async due(signal?: AbortSignal): Promise<readonly string[]> {
84
+ const now = this.clock.nowSeconds(); const snapshot = await this.store.read([{ collection: 'message_outbox' }], signal);
85
+ return snapshot.sets[0]!.map(decodeOutbox).filter(value => pending.has(value.state) && value.nextAttemptAt <= now)
86
+ .sort((a, b) => a.request.envelope.createdAt - b.request.envelope.createdAt || ordinal(a.request.envelope.messageId, b.request.envelope.messageId))
87
+ .map(value => value.request.envelope.messageId);
88
+ }
89
+ /** Errors leave a durable retry state and are returned for visible reporting by the owning runtime. */
90
+ process(messageId: string, signal?: AbortSignal): Promise<{ readonly changes: readonly OutboxChange[]; readonly error?: unknown }> {
91
+ return this.#gate.run(() => this.#process(messageId, signal), signal);
92
+ }
93
+ async #process(messageId: string, signal?: AbortSignal): Promise<{ readonly changes: readonly OutboxChange[]; readonly error?: unknown }> {
94
+ let record = await this.get(messageId, signal); const changes: OutboxChange[] = [];
95
+ if (!record || !pending.has(record.state) || record.nextAttemptAt > this.clock.nowSeconds()) return { changes };
96
+ const previousAcceptancePossible = record.state !== 'queued';
97
+ let submitted = false;
98
+ let acceptedAt = record.acceptedAt;
99
+ try {
100
+ const home = record.state === 'relayAccepted' ? undefined : await this.transport.getHome(signal);
101
+ if (home !== undefined) validateRelayId(home);
102
+ const relayId = record.state === 'queued' ? home! : record.relayId;
103
+ const relay = await this.transport.prepare(relayId, signal); throwIfAborted(signal);
104
+ if (record.state === 'queued') {
105
+ if (this.transport.currentHome() !== relayId) throw new StateConflictError('Home relay changed before claiming the queued message.');
106
+ const claimed = await this.#update(messageId, current => current.state === 'queued' && sameRequest(current, record!) ? { ...current, state: 'submitting', relayId } : undefined, signal);
107
+ if (!claimed) return { changes };
108
+ changes.push(claimed); record = claimed.current;
109
+ }
110
+ const oldRelay = home !== undefined && record.relayId !== this.transport.currentHome();
111
+ if (oldRelay && record.state === 'submitting') {
112
+ const change = await this.#replace(record, { ...record, state: 'queued', nextAttemptAt: this.clock.nowSeconds() }); if (change) changes.push(change);
113
+ return { changes };
114
+ }
115
+ submitted = record.state !== 'relayAccepted' && !oldRelay;
116
+ const delivery = submitted ? await relay.send(messageSendRequestCodec.decode(messageSendRequestCodec.encode(record.request)), signal) : await relay.status(messageId, signal);
117
+ validateMessageDeliveryStatus(delivery);
118
+ if (record.acceptedAt !== undefined && record.acceptedAt !== delivery.acceptedAt) throw new ProtocolError('invalid_delivery', 'Relay changed the original message acceptance time.');
119
+ acceptedAt = delivery.acceptedAt;
120
+ const current = { ...record, state: delivery.status === 'delivering' ? 'relayAccepted' as const : delivery.status === 'target_accepted' ? 'targetAccepted' as const : 'failed' as const,
121
+ acceptedAt: delivery.acceptedAt, errorMessage: delivery.error?.message, nextAttemptAt: this.#retryAt() };
122
+ const change = await this.#replace(record, current); if (change) changes.push(change);
123
+ return { changes };
124
+ } catch (error) {
125
+ // A cancellation can interrupt the request after Relay acceptance; persistence deliberately ignores its signal.
126
+ const definitive = submitted && !previousAcceptancePossible && error instanceof RelayError && error.isDefinitiveRejection;
127
+ const state = record.state === 'queued' ? 'queued' : acceptedAt !== undefined ? 'relayAccepted' : definitive ? 'failed' : 'submissionUnknown';
128
+ const current = { ...record, state, ...(acceptedAt === undefined ? {} : { acceptedAt }), errorMessage: error instanceof Error ? error.message : String(error), nextAttemptAt: this.#retryAt() } as const;
129
+ const change = await this.#replace(record, current); if (change) changes.push(change);
130
+ return { changes, error };
131
+ }
132
+ }
133
+ #retryAt(): number { const result = this.clock.nowSeconds() + 15; requireSafeInteger(result, 0); return result; }
134
+ #replace(previous: OutboxRecord, current: Omit<OutboxRecord, 'errorMessage'> & { readonly errorMessage?: string | undefined }): Promise<OutboxChange | undefined> {
135
+ const fingerprint = canonicalJson(encodeOutbox(previous));
136
+ return this.#update(previous.request.envelope.messageId, saved => canonicalJson(encodeOutbox(saved)) === fingerprint ? current : undefined);
137
+ }
138
+ async #update(messageId: string, update: (record: OutboxRecord) => (Omit<OutboxRecord, 'errorMessage'> & { readonly errorMessage?: string | undefined }) | undefined, signal?: AbortSignal): Promise<OutboxChange | undefined> {
139
+ const result = await updateStore(this.store, [outboxKey(messageId), ...(this.effects?.queries ?? [])], snapshot => {
140
+ const row = snapshot.sets[0]![0]; if (!row) return { mutations: [], result: undefined };
141
+ const previous = decodeOutbox(row); const updated = update(previous); if (!updated) return { mutations: [], result: undefined };
142
+ const { errorMessage, ...rest } = updated; const current: OutboxRecord = { ...rest, ...(errorMessage === undefined ? {} : { errorMessage }) };
143
+ const change = { previous, current };
144
+ const mutation: StoreMutation = current.state === 'targetAccepted' ? { kind: 'delete', ...outboxKey(messageId) } : { kind: 'put', ...outboxKey(messageId), value: encodeOutbox(current) };
145
+ const effects = this.effects?.plan(snapshot, change) ?? [];
146
+ return { mutations: [mutation, ...effects], result: { change, snapshot, effects } };
147
+ }, signal);
148
+ if (result) this.effects?.committed?.(result.snapshot, result.effects); return result?.change;
149
+ }
150
+ }
151
+ function sameRequest(a: OutboxRecord, b: OutboxRecord): boolean { return messageSendRequestCodec.stringify(a.request) === messageSendRequestCodec.stringify(b.request); }
152
+ function ordinal(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; }