@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.100

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 (123) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/LICENSE +92 -0
  3. package/README.md +101 -2
  4. package/bin/hellolynk-client-setup.mjs +2748 -0
  5. package/bin/hellolynk-mcp-config-shape.mjs +23 -0
  6. package/hellolynk-client-pack-manifest.json +619 -0
  7. package/hellolynk-client-pack-pin.json +4 -0
  8. package/package.json +58 -4
  9. package/plugins/hellolynk-codex/.app.json +3 -0
  10. package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
  11. package/plugins/hellolynk-codex/.mcp.json +19 -0
  12. package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
  13. package/plugins/hellolynk-codex/package.json +11 -0
  14. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
  15. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
  16. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
  17. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
  18. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
  19. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
  20. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  21. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
  22. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
  23. package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  24. package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
  25. package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
  26. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
  27. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
  28. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
  29. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
  30. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
  31. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
  32. package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
  33. package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
  34. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
  35. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
  36. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
  37. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
  38. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
  39. package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
  40. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
  41. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
  42. package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
  43. package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
  44. package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
  45. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
  46. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
  47. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
  48. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
  49. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
  50. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
  51. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
  52. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
  53. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
  54. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
  55. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
  56. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
  57. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
  58. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
  59. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
  60. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
  61. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
  62. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
  63. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  64. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
  65. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
  66. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
  67. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
  68. package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
  69. package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
  70. package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +277 -0
  71. package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
  72. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
  73. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
  74. package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
  75. package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
  76. package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
  77. package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
  78. package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
  79. package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
  80. package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
  81. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  82. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
  83. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
  84. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
  85. package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
  86. package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
  87. package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
  88. package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
  89. package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
  90. package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
  91. package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
  92. package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
  93. package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
  94. package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
  95. package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
  96. package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
  97. package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
  98. package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
  99. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
  100. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
  101. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
  102. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
  103. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
  104. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
  105. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
  106. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
  107. package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
  108. package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
  109. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
  110. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
  111. package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
  112. package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
  113. package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
  114. package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
  115. package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
  116. package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
  117. package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
  118. package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
  119. package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
  120. package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
  121. package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
  122. package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
  123. package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
@@ -0,0 +1,3043 @@
1
+ import runtimeSafety from "./hellolynk-runtime-safety.cjs";
2
+ import { nativeConversationId, nativeConversationTitle } from "./hellolynk-native-conversations.mjs";
3
+ import crypto from "node:crypto";
4
+ import { createReadStream } from "node:fs";
5
+ import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
6
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
7
+ import { createInterface } from "node:readline";
8
+ import { normalizeHelloLynkBaseUrl } from "./hellolynk-client-core.mjs";
9
+ import { assertLocalDataProtectionAccess } from "./hellolynk-local-data-protection.mjs";
10
+ import {
11
+ createOutgoingConversationIntent,
12
+ deriveInstanceConversationBinding,
13
+ getMessageConversationBinding,
14
+ getOutgoingConversationIntent,
15
+ instanceConversationKey,
16
+ normalizeInstanceConversationBinding,
17
+ } from "./hellolynk-instance-conversation.mjs";
18
+ import {
19
+ DIRECT_TEXT_E2EE_ALLOWED_INTENT_SOURCES,
20
+ DIRECT_TEXT_E2EE_AUTHORIZATION_SCOPE,
21
+ } from "./hellolynk-direct-text-e2ee.mjs";
22
+
23
+ export const HELLOLYNK_LOCAL_MESSAGE_STORE_CONTRACT = "hellolynk-local-messages-v2";
24
+ export const HELLOLYNK_LOCAL_MESSAGE_STORE_SCHEMA_VERSION = 2;
25
+ const STORE_SCHEMA_VERSION = HELLOLYNK_LOCAL_MESSAGE_STORE_SCHEMA_VERSION;
26
+ const STORE_FIELDS = Object.freeze(["contractId", "messages", "profileId", "schemaVersion"]);
27
+ const ARCHIVE_ENVELOPE_FIELDS = Object.freeze([
28
+ "archivedAt",
29
+ "contractId",
30
+ "message",
31
+ "profileId",
32
+ "schemaVersion",
33
+ ]);
34
+ const CURRENT_MESSAGE_FIELDS = new Set([
35
+ "automaticDeliveryState",
36
+ "attempts",
37
+ "authorizationScope",
38
+ "clientMessageId",
39
+ "conversationBinding",
40
+ "conversationIntent",
41
+ "conversationDeleted",
42
+ "replyToClientMessageId",
43
+ "targetConversationId",
44
+ "targetConversationTitle",
45
+ "friendTargetHeld",
46
+ "replyTargetDeleted",
47
+ "resendSourceClientMessageId",
48
+ "resendClientMessageId",
49
+ "createdAt",
50
+ "deliveryAttemptInstance",
51
+ "deliveryAttemptPid",
52
+ "deliveryAttemptStartedAt",
53
+ "direction",
54
+ "e2ee",
55
+ "e2eePreparation",
56
+ "envelopeDigest",
57
+ "eventId",
58
+ "intentSource",
59
+ "lastErrorCode",
60
+ "localMessageId",
61
+ "messageType",
62
+ "nextRetryAt",
63
+ "peerHelloLynkId",
64
+ "peerPrincipalId",
65
+ "recipientHelloLynkId",
66
+ "recipientIdentityKeyId",
67
+ "receiptSeq",
68
+ "senderDelegateDisplayName",
69
+ "senderDelegateId",
70
+ "senderHelloLynkId",
71
+ "senderIdentityKeyId",
72
+ "senderPrincipalDisplayName",
73
+ "senderPrincipalId",
74
+ "status",
75
+ "text",
76
+ "transportSecurity",
77
+ "updatedAt",
78
+ ]);
79
+ const MAX_STORE_BYTES = 8 * 1024 * 1024;
80
+ const MAX_TEXT_BYTES = 20 * 1024;
81
+ const MAX_JSON_RESPONSE_BYTES = 256 * 1024;
82
+ const MAX_SSE_BUFFER_BYTES = 256 * 1024;
83
+ const RECEIVER_LOCAL_STORE_CONTRACT = "receiver-local-store-v1";
84
+ const RECEIVER_RECEIPT_VERSION = "1";
85
+ const E2EE_DIRECT_TEXT_VERSION = "1";
86
+ const E2EE_TRANSPORT_SECURITY = "e2ee-direct-text-v1";
87
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,199}$/;
88
+ const RETRYABLE_STATUSES = new Set([408, 409, 425, 429]);
89
+ const STORE_LOCK_WAIT_MS = 20;
90
+ const STORE_LOCK_ATTEMPTS = 100;
91
+ const STORE_LOCK_STALE_MS = 5_000;
92
+ const MAX_STORE_LOCK_BYTES = 16 * 1024;
93
+ const MIN_CONFIGURED_STORE_BYTES = 32 * 1024;
94
+ const MAX_ARCHIVE_RECORD_BYTES = 128 * 1024;
95
+ const MAX_PROTECTED_ARCHIVE_RECORD_BYTES = MAX_ARCHIVE_RECORD_BYTES * 2;
96
+ const RETRYABLE_ATOMIC_REPLACE_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
97
+ const DEFAULT_ATOMIC_REPLACE_RETRY_DELAYS_MS = Object.freeze([10, 25, 50, 100, 200, 400]);
98
+ const ARCHIVABLE_OUTGOING_STATUSES = new Set([
99
+ "cancelled",
100
+ "delivered_to_live_connection",
101
+ "delivery_state_unconfirmed",
102
+ "failed_permanent",
103
+ ]);
104
+ const ARCHIVABLE_INCOMING_STATUSES = new Set(["fetched", "pending_human_confirmation"]);
105
+ const DELETABLE_OUTGOING_STATUSES = new Set([
106
+ "cancelled",
107
+ "delivered_to_live_connection",
108
+ "delivery_state_unconfirmed",
109
+ "failed_permanent",
110
+ "failed_retryable",
111
+ "recipient_offline",
112
+ ]);
113
+ const MANUAL_RETRYABLE_OUTGOING_STATUSES = new Set([
114
+ "recipient_offline",
115
+ "failed_retryable",
116
+ "failed_permanent",
117
+ ]);
118
+ const BACKGROUND_RETRYABLE_OUTGOING_STATUSES = new Set([
119
+ "pending",
120
+ "recipient_offline",
121
+ "failed_retryable",
122
+ ]);
123
+ const CLAIMABLE_OUTGOING_STATUSES = new Set([
124
+ ...BACKGROUND_RETRYABLE_OUTGOING_STATUSES,
125
+ ...MANUAL_RETRYABLE_OUTGOING_STATUSES,
126
+ ]);
127
+ const PROCESS_INSTANCE_ID = crypto.randomBytes(16).toString("hex");
128
+ const pathMutationQueues = new Map();
129
+ const LOCAL_HISTORY_CONTRACT = "hellolynk-local-history-v1";
130
+ const LOCAL_DELETED_MESSAGE_TYPE = "local-deleted";
131
+ const MAX_LOCAL_HISTORY_BYTES = 8 * 1024 * 1024;
132
+
133
+ function localHistoryFailure(code) {
134
+ return new HelloLynkLocalMessagingError(`local_history_${code}`, { httpStatus: 409 });
135
+ }
136
+
137
+ function localHistoryScope(input) {
138
+ const region = input?.region === "international" ? "intl" : input?.region;
139
+ if (!["intl", "cn"].includes(region)) throw localHistoryFailure("scope_invalid");
140
+ return { region, principalId: safeId(input?.principalId, "local_history_scope_invalid"),
141
+ delegateId: safeId(input?.delegateId, "local_history_scope_invalid") };
142
+ }
143
+
144
+ function localHistoryScopeKey(scope) {
145
+ return `${scope.region}\0${scope.principalId}\0${scope.delegateId}`;
146
+ }
147
+
148
+ function localHistoryBinding(message, scope) {
149
+ try {
150
+ const binding = getMessageConversationBinding(message, scope)
151
+ || getOutgoingConversationIntent(message, scope);
152
+ return binding && binding.region === scope.region && binding.localPrincipalId === scope.principalId
153
+ && binding.localDelegateId === scope.delegateId ? binding : null;
154
+ } catch { return null; }
155
+ }
156
+
157
+ function localHistoryThreadId(message, scope) {
158
+ const binding = localHistoryBinding(message, scope);
159
+ return binding ? instanceConversationKey(binding) : null;
160
+ }
161
+
162
+ function localHistoryUnread(message) {
163
+ return message.direction === "incoming"
164
+ && (typeof message.unread === "boolean" ? message.unread : message.status === "received");
165
+ }
166
+
167
+ function localHistorySent(message) {
168
+ return message.direction === "outgoing" && message.status === "delivered_to_live_connection";
169
+ }
170
+
171
+ function localHistoryTombstone(message, scope) {
172
+ // Keep the original v2 fields and real terminal status. A deleted body must
173
+ // never turn the sending ledger into a missing/retryable message.
174
+ const fields = ["localMessageId", "clientMessageId", "direction", "peerHelloLynkId", "peerPrincipalId",
175
+ "senderPrincipalId", "senderDelegateId", "senderIdentityKeyId", "recipientIdentityKeyId",
176
+ "envelopeDigest", "eventId", "conversationBinding", "conversationIntent", "authorizationScope",
177
+ "intentSource", "status", "createdAt", "updatedAt", "receiptSeq", "conversationDeleted", "replyToClientMessageId", "targetConversationId", "replyTargetDeleted",
178
+ "resendSourceClientMessageId", "resendClientMessageId"];
179
+ return { ...Object.fromEntries(fields.filter((field) => Object.hasOwn(message, field))
180
+ .map((field) => [field, message[field]])), conversationBinding: localHistoryBinding(message, scope),
181
+ messageType: LOCAL_DELETED_MESSAGE_TYPE, text: "" };
182
+ }
183
+
184
+ export class HelloLynkLocalMessagingError extends Error {
185
+ constructor(code, options = {}) {
186
+ super(code, options.cause ? { cause: options.cause } : undefined);
187
+ this.name = "HelloLynkLocalMessagingError";
188
+ this.code = code;
189
+ this.httpStatus = Number.isInteger(options.httpStatus) ? options.httpStatus : null;
190
+ this.status = this.httpStatus;
191
+ this.retryable = options.retryable === true;
192
+ this.deliveryStatus = options.deliveryStatus || null;
193
+ this.reason = options.reason || null;
194
+ }
195
+ }
196
+
197
+ function delay(ms) {
198
+ return new Promise((resolve) => setTimeout(resolve, ms));
199
+ }
200
+
201
+ function normalizeAtomicReplaceRetryDelays(value) {
202
+ if (value === undefined) return DEFAULT_ATOMIC_REPLACE_RETRY_DELAYS_MS;
203
+ if (!Array.isArray(value) || value.length > 16) {
204
+ throw new TypeError("invalid_atomic_replace_retry_delays");
205
+ }
206
+ const normalized = value.map((item) => Number(item));
207
+ if (normalized.some((item) => !Number.isSafeInteger(item) || item < 0 || item > 1_000)) {
208
+ throw new TypeError("invalid_atomic_replace_retry_delays");
209
+ }
210
+ return normalized;
211
+ }
212
+
213
+ export async function replaceLocalStoreFileWithRetry(sourcePath, targetPath, options = {}) {
214
+ const renameFile = typeof options.renameFile === "function" ? options.renameFile : rename;
215
+ const sleep = typeof options.sleep === "function" ? options.sleep : delay;
216
+ const retryDelaysMs = normalizeAtomicReplaceRetryDelays(options.retryDelaysMs);
217
+ let retryCount = 0;
218
+
219
+ while (true) {
220
+ try {
221
+ await renameFile(sourcePath, targetPath);
222
+ return { attempts: retryCount + 1, retries: retryCount };
223
+ } catch (error) {
224
+ const retryable = RETRYABLE_ATOMIC_REPLACE_CODES.has(String(error?.code || ""));
225
+ if (!retryable || retryCount >= retryDelaysMs.length) throw error;
226
+ await sleep(retryDelaysMs[retryCount]);
227
+ retryCount += 1;
228
+ }
229
+ }
230
+ }
231
+
232
+ async function releaseOwnedStoreLock(lockPath, handle, ownershipToken) {
233
+ let ownedBeforeClose = false;
234
+ try {
235
+ const current = JSON.parse(await readFile(lockPath, "utf8"));
236
+ ownedBeforeClose = current?.ownershipToken === ownershipToken;
237
+ } catch {}
238
+ await handle.close().catch(() => {});
239
+ if (!ownedBeforeClose) return false;
240
+ try {
241
+ const current = JSON.parse(await readFile(lockPath, "utf8"));
242
+ if (current?.ownershipToken !== ownershipToken) return false;
243
+ await rm(lockPath);
244
+ return true;
245
+ } catch {
246
+ return false;
247
+ }
248
+ }
249
+
250
+ async function tryRemoveDeadOwnedLock(lockPath, ownershipField) {
251
+ let metadata;
252
+ try {
253
+ metadata = await lstat(lockPath);
254
+ } catch (error) {
255
+ if (error?.code === "ENOENT") return false;
256
+ throw error;
257
+ }
258
+ if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_STORE_LOCK_BYTES) return false;
259
+ if (Date.now() - metadata.mtimeMs < STORE_LOCK_STALE_MS) return false;
260
+
261
+ let current;
262
+ try {
263
+ current = JSON.parse(await readFile(lockPath, "utf8"));
264
+ } catch {
265
+ return false;
266
+ }
267
+ const ownerPid = Number(current?.pid);
268
+ const ownershipToken = current?.[ownershipField];
269
+ if (
270
+ !Number.isSafeInteger(ownerPid) ||
271
+ ownerPid <= 0 ||
272
+ typeof ownershipToken !== "string" ||
273
+ ownershipToken.length < 16 ||
274
+ !Number.isFinite(Number(current?.createdAt)) ||
275
+ processAlive(ownerPid)
276
+ ) return false;
277
+
278
+ const stalePath = `${lockPath}.stale.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
279
+ try {
280
+ await rename(lockPath, stalePath);
281
+ } catch (error) {
282
+ if (["ENOENT", "EACCES", "EPERM"].includes(error?.code)) return false;
283
+ throw error;
284
+ }
285
+ await rm(stalePath, { force: true });
286
+ return true;
287
+ }
288
+
289
+ async function tryRecoverDeadStoreLock(lockPath) {
290
+ const recoveryPath = `${lockPath}.recovery`;
291
+ const recoveryToken = crypto.randomBytes(24).toString("hex");
292
+ let recoveryHandle = null;
293
+ for (let attempt = 0; attempt < 2; attempt += 1) {
294
+ try {
295
+ recoveryHandle = await open(recoveryPath, "wx", 0o600);
296
+ await recoveryHandle.writeFile(`${JSON.stringify({
297
+ pid: process.pid,
298
+ ownershipToken: recoveryToken,
299
+ createdAt: Date.now(),
300
+ })}\n`, "utf8");
301
+ await recoveryHandle.sync();
302
+ break;
303
+ } catch (error) {
304
+ await recoveryHandle?.close().catch(() => {});
305
+ recoveryHandle = null;
306
+ if (error?.code !== "EEXIST") throw error;
307
+ if (!(await tryRemoveDeadOwnedLock(recoveryPath, "ownershipToken"))) return false;
308
+ }
309
+ }
310
+ if (!recoveryHandle) return false;
311
+
312
+ try {
313
+ return await tryRemoveDeadOwnedLock(lockPath, "ownershipToken");
314
+ } finally {
315
+ await releaseOwnedStoreLock(recoveryPath, recoveryHandle, recoveryToken);
316
+ }
317
+ }
318
+
319
+ function processAlive(pid) {
320
+ try {
321
+ process.kill(pid, 0);
322
+ return true;
323
+ } catch (error) {
324
+ return error?.code === "EPERM";
325
+ }
326
+ }
327
+
328
+ function deliveryAttemptHasLiveOwner(message) {
329
+ const attemptPid = Number(message?.deliveryAttemptPid);
330
+ const attemptInstance = String(message?.deliveryAttemptInstance || "");
331
+ if (!Number.isSafeInteger(attemptPid) || attemptPid <= 0 || !attemptInstance) return false;
332
+ if (attemptPid === process.pid) return attemptInstance === PROCESS_INSTANCE_ID;
333
+ return processAlive(attemptPid);
334
+ }
335
+
336
+ function recoverAbandonedE2eeOutgoingAttempts(store, now, clientMessageId = null) {
337
+ const recovered = [];
338
+ for (const message of store.messages) {
339
+ if (
340
+ message?.direction !== "outgoing" ||
341
+ !["pending", "delivery_state_unknown"].includes(message.status) ||
342
+ (message.status === "pending" && (message.attempts !== 0 || message.e2eePreparation == null)) ||
343
+ (clientMessageId && message.clientMessageId !== clientMessageId)
344
+ ) continue;
345
+
346
+ if (deliveryAttemptHasLiveOwner(message)) continue;
347
+
348
+ // Only the current durable preparation phase proves that dispatch has not
349
+ // started. Legacy counters (including zero) cannot thaw an unknown result.
350
+ const interruptedPreparation = message.status === "pending"
351
+ && message.attempts === 0 && message.e2eePreparation != null;
352
+ message.status = interruptedPreparation ? "failed_retryable" : "delivery_state_unconfirmed";
353
+ message.lastErrorCode = interruptedPreparation ? "e2ee_preparation_interrupted" : "e2ee_delivery_attempt_interrupted";
354
+ message.nextRetryAt = null;
355
+ message.deliveryAttemptPid = null;
356
+ message.deliveryAttemptInstance = null;
357
+ message.deliveryAttemptStartedAt = null;
358
+ message.updatedAt = new Date(now()).toISOString();
359
+ recovered.push(structuredClone(message));
360
+ }
361
+ return recovered;
362
+ }
363
+
364
+ async function withCrossProcessStoreLock(storePath, callback) {
365
+ const lockPath = `${storePath}.lock`;
366
+ // Windows may report EEXIST for an exclusive create below a junction even
367
+ // when no lock exists. Validate the directory chain before interpreting
368
+ // EEXIST as lock contention; the callback validates it again before access.
369
+ await assertMessageStoreDirectoryAccess(storePath, { create: true });
370
+ let handle = null;
371
+ let ownershipToken = null;
372
+ for (let attempt = 0; attempt < STORE_LOCK_ATTEMPTS; attempt += 1) {
373
+ try {
374
+ const candidateHandle = await open(lockPath, "wx", 0o600);
375
+ const candidateToken = crypto.randomBytes(24).toString("hex");
376
+ try {
377
+ await candidateHandle.writeFile(`${JSON.stringify({
378
+ pid: process.pid,
379
+ ownershipToken: candidateToken,
380
+ createdAt: Date.now(),
381
+ })}\n`, "utf8");
382
+ await candidateHandle.sync();
383
+ } catch (error) {
384
+ await releaseOwnedStoreLock(lockPath, candidateHandle, candidateToken);
385
+ throw error;
386
+ }
387
+ handle = candidateHandle;
388
+ ownershipToken = candidateToken;
389
+ break;
390
+ } catch (error) {
391
+ if (error?.code !== "EEXIST") throw error;
392
+ await tryRecoverDeadStoreLock(lockPath);
393
+ await delay(STORE_LOCK_WAIT_MS);
394
+ }
395
+ }
396
+ if (!handle) {
397
+ throw new HelloLynkLocalMessagingError("local_message_store_busy", { retryable: true });
398
+ }
399
+ try {
400
+ return await callback();
401
+ } finally {
402
+ await releaseOwnedStoreLock(lockPath, handle, ownershipToken);
403
+ }
404
+ }
405
+
406
+ async function withPathMutationQueue(storePath, callback) {
407
+ const previous = pathMutationQueues.get(storePath) || Promise.resolve();
408
+ let release;
409
+ const gate = new Promise((resolve) => { release = resolve; });
410
+ const tail = previous.then(() => gate);
411
+ pathMutationQueues.set(storePath, tail);
412
+ await previous;
413
+ try {
414
+ return await withCrossProcessStoreLock(storePath, callback);
415
+ } finally {
416
+ release();
417
+ if (pathMutationQueues.get(storePath) === tail) pathMutationQueues.delete(storePath);
418
+ }
419
+ }
420
+
421
+ // Reuse the short, cross-process filesystem transaction for native worker
422
+ // election. Callers must release it before waiting for a model or a task.
423
+ export async function withHelloLynkLocalMutationLock(targetPath, callback) {
424
+ if (typeof targetPath !== "string" || !isAbsolute(targetPath) || typeof callback !== "function") {
425
+ throw new HelloLynkLocalMessagingError("local_mutation_lock_invalid");
426
+ }
427
+ return withPathMutationQueue(targetPath, callback);
428
+ }
429
+
430
+ function safeId(value, code) {
431
+ const normalized = String(value || "").trim();
432
+ if (!SAFE_ID.test(normalized)) throw new HelloLynkLocalMessagingError(code);
433
+ return normalized;
434
+ }
435
+
436
+ function inboxReaderId(value) {
437
+ if (typeof value !== "string" || safeId(value, "local_inbox_reader_invalid") !== value) {
438
+ throw new HelloLynkLocalMessagingError("local_inbox_reader_invalid");
439
+ }
440
+ return value;
441
+ }
442
+
443
+ function safeText(value) {
444
+ if (typeof value !== "string" || value.length === 0) {
445
+ throw new HelloLynkLocalMessagingError("local_message_text_required");
446
+ }
447
+ if (Buffer.byteLength(value, "utf8") > MAX_TEXT_BYTES) {
448
+ throw new HelloLynkLocalMessagingError("local_message_text_too_large");
449
+ }
450
+ return value;
451
+ }
452
+
453
+ function throwIfAborted(signal) {
454
+ if (signal?.aborted) {
455
+ throw new HelloLynkLocalMessagingError("e2ee_preparation_cancelled", { retryable: true });
456
+ }
457
+ }
458
+
459
+ function safeLocalErrorCode(error) {
460
+ const code = String(error?.code || "delivery_state_unknown");
461
+ return /^[a-z][a-z0-9_]{1,127}$/.test(code) ? code : "delivery_state_unknown";
462
+ }
463
+
464
+ function normalizeOutgoingRoute(value, e2eeVersion) {
465
+ if (value == null) return null;
466
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
467
+ throw new HelloLynkLocalMessagingError("e2ee_route_invalid");
468
+ }
469
+ const fields = Object.keys(value);
470
+ if (value.routeKind === "instance_direct") {
471
+ if (fields.length !== 2 || !Object.hasOwn(value, "recipientDelegateId")) {
472
+ throw new HelloLynkLocalMessagingError("e2ee_route_invalid");
473
+ }
474
+ if (String(e2eeVersion || "1") !== "2") {
475
+ throw new HelloLynkLocalMessagingError("e2ee_route_version_unsupported");
476
+ }
477
+ return {
478
+ routeKind: "instance_direct",
479
+ recipientDelegateId: safeId(value.recipientDelegateId, "e2ee_recipient_delegate_invalid"),
480
+ };
481
+ }
482
+ if (value.routeKind !== "owned_delegate") {
483
+ throw new HelloLynkLocalMessagingError("e2ee_route_kind_invalid");
484
+ }
485
+ const allowed = new Set(["routeKind", "recipientDelegateId", "recipientHostId", "recipientDeviceId"]);
486
+ if (
487
+ fields.some((field) => !allowed.has(field))
488
+ || !Object.hasOwn(value, "recipientDelegateId")
489
+ || !Object.hasOwn(value, "recipientHostId")
490
+ || (fields.length !== 3 && fields.length !== 4)
491
+ ) throw new HelloLynkLocalMessagingError("e2ee_route_invalid");
492
+ if (String(e2eeVersion || "1") !== "2") {
493
+ throw new HelloLynkLocalMessagingError("e2ee_route_version_unsupported");
494
+ }
495
+ return {
496
+ routeKind: "owned_delegate",
497
+ recipientDelegateId: safeId(value.recipientDelegateId, "e2ee_recipient_delegate_invalid"),
498
+ recipientHostId: safeId(value.recipientHostId, "e2ee_recipient_host_invalid"),
499
+ ...(value.recipientDeviceId == null ? {} : {
500
+ recipientDeviceId: safeId(value.recipientDeviceId, "e2ee_recipient_device_invalid"),
501
+ }),
502
+ };
503
+ }
504
+
505
+ function optionalVerifiedDisplayName(value, code) {
506
+ if (value == null) return null;
507
+ const normalized = String(value).trim();
508
+ if (
509
+ normalized.length < 1 || [...normalized].length > 80 ||
510
+ /[\u0000-\u001F\u007F-\u009F\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]/u.test(normalized)
511
+ ) throw new HelloLynkLocalMessagingError(code);
512
+ return normalized;
513
+ }
514
+
515
+ function normalizeMessageProvenance(authorizationScope, intentSource, options = {}) {
516
+ const scopeMissing = authorizationScope == null;
517
+ const intentMissing = intentSource == null;
518
+ if (scopeMissing && intentMissing && options.required !== true) {
519
+ return { authorizationScope: null, intentSource: null };
520
+ }
521
+ if (
522
+ scopeMissing !== intentMissing
523
+ || authorizationScope !== DIRECT_TEXT_E2EE_AUTHORIZATION_SCOPE
524
+ || !DIRECT_TEXT_E2EE_ALLOWED_INTENT_SOURCES.includes(intentSource)
525
+ ) throw new HelloLynkLocalMessagingError(options.code || "message_provenance_invalid");
526
+ return { authorizationScope, intentSource };
527
+ }
528
+
529
+ function provenanceFromStoredE2ee(e2ee, senderPrincipalId, senderDelegateId) {
530
+ if (e2ee == null) return { authorizationScope: null, intentSource: null };
531
+ const protectedMetadata = e2ee.envelope?.protected;
532
+ if (
533
+ !protectedMetadata || typeof protectedMetadata !== "object" || Array.isArray(protectedMetadata)
534
+ || protectedMetadata.senderPrincipalId !== senderPrincipalId
535
+ || protectedMetadata.senderDelegateId !== senderDelegateId
536
+ ) throw new HelloLynkLocalMessagingError("e2ee_outgoing_provenance_invalid");
537
+ return normalizeMessageProvenance(
538
+ protectedMetadata.authorizationScope,
539
+ protectedMetadata.intentSource,
540
+ { required: true, code: "e2ee_outgoing_provenance_invalid" },
541
+ );
542
+ }
543
+
544
+ function safeSha256(value, code = "e2ee_envelope_digest_invalid") {
545
+ const normalized = String(value || "").toLowerCase();
546
+ if (!/^[a-f0-9]{64}$/.test(normalized)) throw new HelloLynkLocalMessagingError(code);
547
+ return normalized;
548
+ }
549
+
550
+ function normalizeStoredE2ee(value) {
551
+ if (value == null) return null;
552
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
553
+ throw new HelloLynkLocalMessagingError("e2ee_outgoing_contract_invalid");
554
+ }
555
+ const claimId = safeId(value.claimId, "e2ee_claim_id_invalid");
556
+ const envelopeDigest = safeSha256(value.envelopeDigest);
557
+ const envelope = value.envelope;
558
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
559
+ throw new HelloLynkLocalMessagingError("e2ee_envelope_invalid");
560
+ }
561
+ const serialized = JSON.stringify(envelope);
562
+ if (Buffer.byteLength(serialized, "utf8") > MAX_ARCHIVE_RECORD_BYTES) {
563
+ throw new HelloLynkLocalMessagingError("e2ee_envelope_too_large");
564
+ }
565
+ const calculatedDigest = crypto.createHash("sha256").update(serialized, "utf8").digest("hex");
566
+ if (calculatedDigest !== envelopeDigest) {
567
+ throw new HelloLynkLocalMessagingError("e2ee_envelope_digest_mismatch");
568
+ }
569
+ const version = String(value.version || (
570
+ envelope.schemaVersion === 2 ? "2" : E2EE_DIRECT_TEXT_VERSION
571
+ ));
572
+ if (!["1", "2"].includes(version) || (version === "2" && Number(envelope.schemaVersion) !== 2)) {
573
+ throw new HelloLynkLocalMessagingError("e2ee_version_unsupported");
574
+ }
575
+ if (Object.hasOwn(value, "nativeContentVersion") && (version !== "2" || value.nativeContentVersion !== 1))
576
+ throw new HelloLynkLocalMessagingError("e2ee_outgoing_contract_invalid");
577
+ const route = version === "2" ? {
578
+ routeKind: ["principal_inbox", "owned_delegate", "instance_direct"].includes(value.routeKind)
579
+ ? value.routeKind : (() => { throw new HelloLynkLocalMessagingError("e2ee_route_kind_invalid"); })(),
580
+ recipientDelegateId: safeId(value.recipientDelegateId, "e2ee_recipient_delegate_invalid"),
581
+ recipientHostId: safeId(value.recipientHostId, "e2ee_recipient_host_invalid"),
582
+ recipientDeviceId: safeId(value.recipientDeviceId, "e2ee_recipient_device_invalid"),
583
+ } : {};
584
+ return {
585
+ version,
586
+ ...route,
587
+ ...(value.nativeContentVersion === 1 ? { nativeContentVersion: 1 } : {}),
588
+ claimId,
589
+ envelopeDigest,
590
+ envelope: structuredClone(envelope),
591
+ };
592
+ }
593
+
594
+ function publicRemoteCode(value, fallback) {
595
+ const code = String(value || "");
596
+ return /^[a-z][a-z0-9_]{0,63}$/.test(code) ? code : fallback;
597
+ }
598
+
599
+ async function readBoundedText(response, maximum = MAX_JSON_RESPONSE_BYTES) {
600
+ const declared = Number(response.headers.get("content-length"));
601
+ if (Number.isFinite(declared) && declared > maximum) {
602
+ // Dispose the unread response without delaying or changing the send result.
603
+ try { void Promise.resolve(response.body?.cancel()).catch(() => {}); } catch {}
604
+ throw new HelloLynkLocalMessagingError("relay_response_too_large");
605
+ }
606
+ if (!response.body?.getReader) throw new HelloLynkLocalMessagingError("relay_response_body_missing");
607
+ const reader = response.body.getReader();
608
+ const chunks = [];
609
+ let total = 0;
610
+ try {
611
+ while (true) {
612
+ const { done, value } = await reader.read();
613
+ if (done) break;
614
+ total += value.byteLength;
615
+ if (total > maximum) throw new HelloLynkLocalMessagingError("relay_response_too_large");
616
+ chunks.push(value);
617
+ }
618
+ } catch (error) {
619
+ try { void Promise.resolve(reader.cancel()).catch(() => {}); } catch {}
620
+ throw error;
621
+ } finally {
622
+ reader.releaseLock();
623
+ }
624
+ const body = new Uint8Array(total);
625
+ let offset = 0;
626
+ for (const chunk of chunks) {
627
+ body.set(chunk, offset);
628
+ offset += chunk.byteLength;
629
+ }
630
+ return new TextDecoder().decode(body);
631
+ }
632
+
633
+ function hasExactFields(value, expected) {
634
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
635
+ const observed = Object.keys(value).sort();
636
+ return observed.length === expected.length
637
+ && observed.every((field, index) => field === expected[index]);
638
+ }
639
+
640
+ function validateCurrentMessage(message) {
641
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
642
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
643
+ }
644
+ if (Object.keys(message).some((field) => !CURRENT_MESSAGE_FIELDS.has(field))) {
645
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
646
+ }
647
+ if (Object.hasOwn(message, "automaticDeliveryState") && (message.direction !== "incoming" || message.automaticDeliveryState !== "expired")) {
648
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
649
+ }
650
+ if (Object.hasOwn(message, "conversationDeleted") && (message.conversationDeleted !== true || message.messageType !== LOCAL_DELETED_MESSAGE_TYPE)) {
651
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
652
+ }
653
+ if (Object.hasOwn(message, "targetConversationId")) {
654
+ nativeConversationId(message.targetConversationId);
655
+ if (message.replyToClientMessageId) throw new HelloLynkLocalMessagingError("native_target_conflicts_with_reply");
656
+ }
657
+ if (Object.hasOwn(message, "targetConversationTitle")) {
658
+ if (message.direction !== "outgoing" || !message.targetConversationId) throw new HelloLynkLocalMessagingError("local_message_store_invalid");
659
+ nativeConversationTitle(message.targetConversationTitle);
660
+ }
661
+ if (Object.hasOwn(message, "friendTargetHeld") && (message.friendTargetHeld !== true || message.direction !== "incoming"
662
+ || !message.targetConversationId)) throw new HelloLynkLocalMessagingError("local_message_store_invalid");
663
+ if (Object.hasOwn(message, "replyToClientMessageId")) {
664
+ safeId(message.replyToClientMessageId, "local_message_store_invalid");
665
+ if (message.replyToClientMessageId === message.clientMessageId) throw new HelloLynkLocalMessagingError("local_message_store_invalid");
666
+ }
667
+ if (Object.hasOwn(message, "replyTargetDeleted") && (message.replyTargetDeleted !== true || !message.replyToClientMessageId
668
+ || message.direction !== "incoming" || message.messageType !== LOCAL_DELETED_MESSAGE_TYPE)) throw new HelloLynkLocalMessagingError("local_message_store_invalid");
669
+ for (const field of ["resendSourceClientMessageId", "resendClientMessageId"]) if (Object.hasOwn(message, field)) {
670
+ safeId(message[field], "local_message_store_invalid");
671
+ if (message.direction !== "outgoing" || message[field] === message.clientMessageId) throw new HelloLynkLocalMessagingError("local_message_store_invalid");
672
+ }
673
+ safeId(message.localMessageId, "local_message_store_invalid");
674
+ safeId(message.clientMessageId, "local_message_store_invalid");
675
+ if (Object.hasOwn(message, "receiptSeq") && (message.direction !== "incoming"
676
+ || !Number.isSafeInteger(message.receiptSeq) || message.receiptSeq < 1)) {
677
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
678
+ }
679
+ if (!["incoming", "outgoing"].includes(message.direction)
680
+ || !Object.hasOwn(message, "peerHelloLynkId")) {
681
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
682
+ }
683
+ if (message.peerHelloLynkId == null) {
684
+ if (message.direction !== "incoming") {
685
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
686
+ }
687
+ } else {
688
+ safeId(message.peerHelloLynkId, "local_message_store_invalid");
689
+ }
690
+ for (const field of ["recipientHelloLynkId", "senderHelloLynkId"]) {
691
+ if (Object.hasOwn(message, field) && message[field] != null) {
692
+ safeId(message[field], "local_message_store_invalid");
693
+ }
694
+ }
695
+ if (message.conversationBinding != null || message.conversationIntent != null) {
696
+ try {
697
+ if (message.direction !== "outgoing" && message.conversationIntent != null) {
698
+ throw new Error("incoming_conversation_intent_invalid");
699
+ }
700
+ getMessageConversationBinding(message);
701
+ } catch (cause) {
702
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid", { cause });
703
+ }
704
+ }
705
+ if (message.e2eePreparation != null) {
706
+ const preparation = message.e2eePreparation;
707
+ if (message.direction !== "outgoing" || !hasExactFields(preparation, ["intentSource", "route"])
708
+ || !DIRECT_TEXT_E2EE_ALLOWED_INTENT_SOURCES.includes(preparation.intentSource)) {
709
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
710
+ }
711
+ normalizeOutgoingRoute(preparation.route, "2");
712
+ }
713
+ return message;
714
+ }
715
+
716
+ export function assertHelloLynkLocalMessageStore(input, profileId) {
717
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
718
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
719
+ }
720
+ if (!hasExactFields(input, [...STORE_FIELDS,
721
+ ...(Object.hasOwn(input, "receiptHighWater") ? ["receiptHighWater"] : []),
722
+ ...(Object.hasOwn(input, "inboxReaders") ? ["inboxReaders"] : []),
723
+ ...(Object.hasOwn(input, "inboxClaims") ? ["inboxClaims"] : []),
724
+ ].sort())
725
+ || input.contractId !== HELLOLYNK_LOCAL_MESSAGE_STORE_CONTRACT
726
+ || input.schemaVersion !== STORE_SCHEMA_VERSION
727
+ || input.profileId !== profileId
728
+ || !Array.isArray(input.messages)) {
729
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
730
+ }
731
+ for (const message of input.messages) validateCurrentMessage(message);
732
+ const highWater = Object.hasOwn(input, "receiptHighWater") ? input.receiptHighWater : 0;
733
+ if (!Number.isSafeInteger(highWater) || highWater < 0
734
+ || input.messages.some((message) => message.receiptSeq > highWater)) {
735
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
736
+ }
737
+ if (Object.hasOwn(input, "inboxReaders")) {
738
+ if (!Object.hasOwn(input, "receiptHighWater") || !Array.isArray(input.inboxReaders) || input.inboxReaders.length > 1024) {
739
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
740
+ }
741
+ const seen = new Set();
742
+ for (const reader of input.inboxReaders) {
743
+ if (!hasExactFields(reader, ["adapterId", "afterSeq", "delegateId"])
744
+ || !Number.isSafeInteger(reader.afterSeq) || reader.afterSeq < 0 || reader.afterSeq > highWater) {
745
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
746
+ }
747
+ const key = JSON.stringify([inboxReaderId(reader.adapterId), inboxReaderId(reader.delegateId)]);
748
+ if (seen.has(key)) throw new HelloLynkLocalMessagingError("local_message_store_invalid");
749
+ seen.add(key);
750
+ }
751
+ }
752
+ if (Object.hasOwn(input, "inboxClaims")) {
753
+ if (!Array.isArray(input.inboxClaims) || !Array.isArray(input.inboxReaders)) {
754
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
755
+ }
756
+ const seen = new Set();
757
+ const claimIds = new Set();
758
+ for (const claim of input.inboxClaims) {
759
+ if (!hasExactFields(claim, ["adapterId", "claimId", "conversationKey", "delegateId", "localMessageId", "outcome", "receiptSeq"])
760
+ || !["inflight", "completed", "unknown"].includes(claim.outcome)
761
+ || !Number.isSafeInteger(claim.receiptSeq) || claim.receiptSeq <= 0 || claim.receiptSeq > highWater
762
+ || !/^inclaim_[a-f0-9]{48}$/.test(claim.claimId)) throw new HelloLynkLocalMessagingError("local_message_store_invalid");
763
+ for (const key of ["adapterId", "delegateId", "localMessageId", "conversationKey"]) inboxReaderId(claim[key]);
764
+ const reader = input.inboxReaders.find((item) => item.adapterId === claim.adapterId && item.delegateId === claim.delegateId);
765
+ const key = JSON.stringify([claim.delegateId, claim.localMessageId]);
766
+ if (!reader || claim.receiptSeq <= reader.afterSeq || seen.has(key) || claimIds.has(claim.claimId)) {
767
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
768
+ }
769
+ seen.add(key);
770
+ claimIds.add(claim.claimId);
771
+ }
772
+ }
773
+ return input;
774
+ }
775
+
776
+ export function assertHelloLynkLocalMessageArchiveEnvelope(input, profileId) {
777
+ if (!hasExactFields(input, ARCHIVE_ENVELOPE_FIELDS)
778
+ || input.contractId !== HELLOLYNK_LOCAL_MESSAGE_STORE_CONTRACT
779
+ || input.schemaVersion !== STORE_SCHEMA_VERSION
780
+ || input.profileId !== profileId
781
+ || typeof input.archivedAt !== "string"
782
+ || !input.message
783
+ || typeof input.message !== "object"
784
+ || Array.isArray(input.message)) {
785
+ throw new HelloLynkLocalMessagingError("local_message_archive_invalid");
786
+ }
787
+ try {
788
+ validateCurrentMessage(input.message);
789
+ } catch (error) {
790
+ throw new HelloLynkLocalMessagingError("local_message_archive_invalid", { cause: error });
791
+ }
792
+ return input;
793
+ }
794
+
795
+ function emptyStore(profileId) {
796
+ return {
797
+ contractId: HELLOLYNK_LOCAL_MESSAGE_STORE_CONTRACT,
798
+ schemaVersion: STORE_SCHEMA_VERSION,
799
+ profileId,
800
+ messages: [],
801
+ };
802
+ }
803
+
804
+ async function readStore(path, profileId, maximumBytes = MAX_STORE_BYTES, protection = null) {
805
+ let handle;
806
+ try {
807
+ await assertMessageStoreAccess(path, protection, { create: false });
808
+ const physicalMaximum = protectedStoreMaximum(maximumBytes, protection);
809
+ const metadata = await lstat(path);
810
+ if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > physicalMaximum) {
811
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
812
+ }
813
+ handle = await open(path, "r");
814
+ const openedMetadata = await handle.stat();
815
+ if (!openedMetadata.isFile() || openedMetadata.size > physicalMaximum
816
+ || (Number.isSafeInteger(metadata.dev) && Number.isSafeInteger(metadata.ino)
817
+ && Number.isSafeInteger(openedMetadata.dev) && Number.isSafeInteger(openedMetadata.ino)
818
+ && ((process.platform !== "win32" && metadata.dev !== openedMetadata.dev) || metadata.ino !== openedMetadata.ino))) {
819
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
820
+ }
821
+ const stored = JSON.parse(await handle.readFile("utf8"));
822
+ const opened = protection
823
+ ? await protection.protector.openJson(stored, protection.mainContext)
824
+ : stored;
825
+ if (storeByteLength(opened) > maximumBytes) {
826
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid");
827
+ }
828
+ return assertHelloLynkLocalMessageStore(opened, profileId);
829
+ } catch (error) {
830
+ if (error?.code === "ENOENT") return emptyStore(profileId);
831
+ if (error instanceof HelloLynkLocalMessagingError) throw error;
832
+ throw new HelloLynkLocalMessagingError("local_message_store_invalid", { cause: error });
833
+ } finally {
834
+ await handle?.close().catch(() => {});
835
+ }
836
+ }
837
+
838
+ async function syncParentDirectory(path) {
839
+ let handle = null;
840
+ try {
841
+ handle = await open(dirname(path), "r");
842
+ await handle.sync();
843
+ } catch (error) {
844
+ if (!["EBADF", "EINVAL", "EISDIR", "ENOTSUP", "EPERM"].includes(error?.code)) throw error;
845
+ } finally {
846
+ await handle?.close().catch(() => {});
847
+ }
848
+ }
849
+
850
+ async function atomicWriteStore(path, store, maximumBytes = MAX_STORE_BYTES, protection = null) {
851
+ await assertMessageStoreAccess(path, protection, { create: true });
852
+ const temporary = `${path}.${crypto.randomUUID()}.tmp`;
853
+ if (storeByteLength(store) > maximumBytes) {
854
+ throw new HelloLynkLocalMessagingError("local_message_store_too_large");
855
+ }
856
+ const stored = protection
857
+ ? await protection.protector.sealJson(store, protection.mainContext)
858
+ : store;
859
+ const body = `${JSON.stringify(stored, null, 2)}\n`;
860
+ if (Buffer.byteLength(body, "utf8") > protectedStoreMaximum(maximumBytes, protection)) {
861
+ throw new HelloLynkLocalMessagingError("local_message_store_too_large");
862
+ }
863
+ const handle = await open(temporary, "wx", 0o600);
864
+ try {
865
+ await handle.writeFile(body, "utf8");
866
+ await handle.sync();
867
+ } finally {
868
+ await handle.close();
869
+ }
870
+ try {
871
+ await assertMessageStoreAccess(path, protection, { create: false });
872
+ await replaceLocalStoreFileWithRetry(temporary, path);
873
+ await assertMessageStoreAccess(path, protection, { create: false });
874
+ await syncParentDirectory(path);
875
+ } catch (error) {
876
+ await rm(temporary, { force: true });
877
+ throw error;
878
+ }
879
+ }
880
+
881
+ function storeByteLength(store) {
882
+ return Buffer.byteLength(`${JSON.stringify(store, null, 2)}\n`, "utf8");
883
+ }
884
+
885
+ function protectedStoreMaximum(maximumBytes, protection) {
886
+ return protection ? maximumBytes * 2 : maximumBytes;
887
+ }
888
+
889
+ function isArchivableMessage(message) {
890
+ return message?.direction === "outgoing"
891
+ ? ARCHIVABLE_OUTGOING_STATUSES.has(message.status)
892
+ : message?.direction === "incoming" && ARCHIVABLE_INCOMING_STATUSES.has(message.status);
893
+ }
894
+
895
+ async function archiveEnvelope(profileId, message, protection = null) {
896
+ const value = {
897
+ contractId: HELLOLYNK_LOCAL_MESSAGE_STORE_CONTRACT,
898
+ schemaVersion: STORE_SCHEMA_VERSION,
899
+ profileId,
900
+ archivedAt: new Date().toISOString(),
901
+ message,
902
+ };
903
+ return protection
904
+ ? protection.protector.sealArchiveLine(value, protection.archiveContext)
905
+ : JSON.stringify(value);
906
+ }
907
+
908
+ async function parseArchiveEnvelope(line, profileId, protection = null) {
909
+ let parsed;
910
+ try {
911
+ parsed = protection
912
+ ? await protection.protector.openArchiveLine(line, protection.archiveContext)
913
+ : JSON.parse(line);
914
+ } catch (error) {
915
+ throw new HelloLynkLocalMessagingError("local_message_archive_invalid", { cause: error });
916
+ }
917
+ return assertHelloLynkLocalMessageArchiveEnvelope(parsed, profileId).message;
918
+ }
919
+
920
+ async function recoverInterruptedArchiveTail(path, profileId, protection = null) {
921
+ await assertMessageStoreAccess(path, protection, { create: false });
922
+ const handle = await open(path, "r+");
923
+ let changed = false;
924
+ try {
925
+ const metadata = await handle.stat();
926
+ if (metadata.size === 0) return false;
927
+ const lastByte = Buffer.alloc(1);
928
+ await handle.read(lastByte, 0, 1, metadata.size - 1);
929
+ if (lastByte[0] === 0x0a) return false;
930
+
931
+ let cursor = metadata.size;
932
+ let lastNewline = -1;
933
+ while (cursor > 0) {
934
+ const start = Math.max(0, cursor - 64 * 1024);
935
+ const chunk = Buffer.alloc(cursor - start);
936
+ await handle.read(chunk, 0, chunk.length, start);
937
+ const offset = chunk.lastIndexOf(0x0a);
938
+ if (offset >= 0) {
939
+ lastNewline = start + offset;
940
+ break;
941
+ }
942
+ cursor = start;
943
+ }
944
+
945
+ const tailStart = lastNewline + 1;
946
+ const tailLength = metadata.size - tailStart;
947
+ let completeEnvelope = false;
948
+ const maximumTailBytes = protection
949
+ ? MAX_PROTECTED_ARCHIVE_RECORD_BYTES
950
+ : MAX_ARCHIVE_RECORD_BYTES;
951
+ if (tailLength <= maximumTailBytes) {
952
+ const tail = Buffer.alloc(tailLength);
953
+ await handle.read(tail, 0, tail.length, tailStart);
954
+ const tailText = tail.toString("utf8");
955
+ if (protection) {
956
+ let syntacticallyComplete = false;
957
+ try {
958
+ JSON.parse(tailText);
959
+ syntacticallyComplete = true;
960
+ } catch {
961
+ // A syntactically incomplete final write may be truncated. A complete
962
+ // protected envelope that fails authentication must never be repaired.
963
+ }
964
+ if (syntacticallyComplete) {
965
+ await parseArchiveEnvelope(tailText, profileId, protection);
966
+ completeEnvelope = true;
967
+ }
968
+ } else {
969
+ try {
970
+ await parseArchiveEnvelope(tailText, profileId, null);
971
+ completeEnvelope = true;
972
+ } catch (error) {
973
+ if (!(error instanceof HelloLynkLocalMessagingError)) throw error;
974
+ }
975
+ }
976
+ }
977
+
978
+ if (completeEnvelope) {
979
+ await handle.write("\n", metadata.size, "utf8");
980
+ } else {
981
+ await handle.truncate(tailStart);
982
+ }
983
+ await handle.sync();
984
+ changed = true;
985
+ } finally {
986
+ await handle.close();
987
+ }
988
+ if (changed) await syncParentDirectory(path);
989
+ return changed;
990
+ }
991
+
992
+ async function appendArchiveMessages(path, profileId, messages, protection = null) {
993
+ if (messages.length === 0) return;
994
+ await assertMessageStoreAccess(path, protection, { create: true });
995
+ let archiveExists = true;
996
+ try {
997
+ const metadata = await lstat(path);
998
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
999
+ throw new HelloLynkLocalMessagingError("local_message_archive_invalid");
1000
+ }
1001
+ } catch (error) {
1002
+ if (error?.code !== "ENOENT") throw error;
1003
+ archiveExists = false;
1004
+ }
1005
+ if (archiveExists) await recoverInterruptedArchiveTail(path, profileId, protection);
1006
+ const handle = await open(path, "a", 0o600);
1007
+ try {
1008
+ const lines = await Promise.all(
1009
+ messages.map((message) => archiveEnvelope(profileId, message, protection)),
1010
+ );
1011
+ await assertMessageStoreAccess(path, protection, { create: false });
1012
+ await handle.writeFile(`${lines.join("\n")}\n`, "utf8");
1013
+ await handle.sync();
1014
+ } finally {
1015
+ await handle.close();
1016
+ }
1017
+ await syncParentDirectory(path);
1018
+ }
1019
+
1020
+ async function replaceArchiveMessages(path, profileId, messages, protection = null) {
1021
+ await assertMessageStoreAccess(path, protection, { create: true });
1022
+ const temporary = `${path}.${crypto.randomUUID()}.tmp`;
1023
+ const lines = await Promise.all(
1024
+ messages.map((message) => archiveEnvelope(profileId, message, protection)),
1025
+ );
1026
+ const body = messages.length === 0
1027
+ ? ""
1028
+ : `${lines.join("\n")}\n`;
1029
+ const handle = await open(temporary, "wx", 0o600);
1030
+ try {
1031
+ await handle.writeFile(body, "utf8");
1032
+ await handle.sync();
1033
+ } finally {
1034
+ await handle.close();
1035
+ }
1036
+ try {
1037
+ await assertMessageStoreAccess(path, protection, { create: false });
1038
+ await replaceLocalStoreFileWithRetry(temporary, path);
1039
+ await assertMessageStoreAccess(path, protection, { create: false });
1040
+ await syncParentDirectory(path);
1041
+ } catch (error) {
1042
+ await rm(temporary, { force: true });
1043
+ throw error;
1044
+ }
1045
+ }
1046
+
1047
+ async function scanArchive(path, profileId, predicate = null, protection = null) {
1048
+ try {
1049
+ await assertMessageStoreAccess(path, protection, { create: false });
1050
+ const metadata = await lstat(path);
1051
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
1052
+ throw new HelloLynkLocalMessagingError("local_message_archive_invalid");
1053
+ }
1054
+ } catch (error) {
1055
+ if (error?.code === "ENOENT") return predicate ? null : [];
1056
+ throw error;
1057
+ }
1058
+ await recoverInterruptedArchiveTail(path, profileId, protection);
1059
+ await assertMessageStoreAccess(path, protection, { create: false });
1060
+ const stream = createReadStream(path, { encoding: "utf8" });
1061
+ const reader = createInterface({ input: stream, crlfDelay: Infinity });
1062
+ const messages = [];
1063
+ let latestMatch = null;
1064
+ try {
1065
+ for await (const line of reader) {
1066
+ if (!line) continue;
1067
+ const message = await parseArchiveEnvelope(line, profileId, protection);
1068
+ if (predicate) {
1069
+ if (predicate(message)) latestMatch = message;
1070
+ } else {
1071
+ messages.push(message);
1072
+ }
1073
+ }
1074
+ } finally {
1075
+ reader.close();
1076
+ stream.destroy();
1077
+ }
1078
+ return predicate
1079
+ ? (latestMatch ? structuredClone(latestMatch) : null)
1080
+ : structuredClone(messages);
1081
+ }
1082
+
1083
+ async function assertMessageStoreDirectoryAccess(filePath, options = {}) {
1084
+ const parent = dirname(resolve(filePath));
1085
+ if (options.create === true) await mkdir(parent, { recursive: true, mode: 0o700 });
1086
+ const root = parse(parent).root;
1087
+ let cursor = root;
1088
+ for (const segment of relative(root, parent).split(sep).filter(Boolean)) {
1089
+ cursor = join(cursor, segment);
1090
+ const metadata = await lstat(cursor);
1091
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
1092
+ throw new HelloLynkLocalMessagingError("local_message_store_directory_invalid");
1093
+ }
1094
+ }
1095
+ return parent;
1096
+ }
1097
+
1098
+ async function assertMessageStoreAccess(filePath, protection, options = {}) {
1099
+ const parent = await assertMessageStoreDirectoryAccess(filePath, options);
1100
+ const dataRoot = ["messages", "contacts", "contact-requests"].includes(basename(parent))
1101
+ ? dirname(parent)
1102
+ : parent;
1103
+ try {
1104
+ await assertLocalDataProtectionAccess(dataRoot, protection != null);
1105
+ } catch (error) {
1106
+ throw new HelloLynkLocalMessagingError(error?.code || "local_message_store_access_invalid", { cause: error });
1107
+ }
1108
+ }
1109
+
1110
+ function findLatestIndex(items, predicate) {
1111
+ for (let index = items.length - 1; index >= 0; index -= 1) {
1112
+ if (predicate(items[index])) return index;
1113
+ }
1114
+ return -1;
1115
+ }
1116
+
1117
+ function normalizeMessageDataProtection(protector, profileId) {
1118
+ if (protector == null) return null;
1119
+ if (["sealJson", "openJson", "sealArchiveLine", "openArchiveLine"].some(
1120
+ (method) => typeof protector?.[method] !== "function",
1121
+ )) {
1122
+ throw new HelloLynkLocalMessagingError("local_message_data_protector_invalid");
1123
+ }
1124
+ return Object.freeze({
1125
+ protector,
1126
+ mainContext: Object.freeze({
1127
+ profileId,
1128
+ assetKind: "messages",
1129
+ logicalPath: `messages/${profileId}.json`,
1130
+ }),
1131
+ archiveContext: Object.freeze({
1132
+ profileId,
1133
+ assetKind: "messages-archive-line",
1134
+ logicalPath: `messages/${profileId}.json.archive.ndjson`,
1135
+ }),
1136
+ });
1137
+ }
1138
+
1139
+ async function compactFinalMessages({
1140
+ store,
1141
+ archivePath,
1142
+ profileId,
1143
+ triggerBytes,
1144
+ targetBytes,
1145
+ protection = null,
1146
+ }) {
1147
+ if (storeByteLength(store) <= triggerBytes) return 0;
1148
+ const candidates = store.messages
1149
+ .filter(isArchivableMessage)
1150
+ .sort((left, right) => String(left.updatedAt || left.createdAt).localeCompare(String(right.updatedAt || right.createdAt)));
1151
+ const archived = [];
1152
+ const archivedIds = new Set();
1153
+ for (const message of candidates) {
1154
+ archived.push(message);
1155
+ archivedIds.add(message.localMessageId);
1156
+ const retained = store.messages.filter((item) => !archivedIds.has(item.localMessageId));
1157
+ if (storeByteLength({ ...store, messages: retained }) <= targetBytes) break;
1158
+ }
1159
+ if (archived.length === 0) return 0;
1160
+ await appendArchiveMessages(archivePath, profileId, archived, protection);
1161
+ store.messages = store.messages.filter((item) => !archivedIds.has(item.localMessageId));
1162
+ return archived.length;
1163
+ }
1164
+
1165
+ export function createLocalMessageStore(options = {}) {
1166
+ const profileId = safeId(options.profileId, "local_message_profile_id_invalid");
1167
+ const storePath = String(options.storePath || "").trim();
1168
+ const archivePath = `${storePath}.archive.ndjson`;
1169
+ const now = typeof options.now === "function" ? options.now : () => Date.now();
1170
+ const configuredMaximum = Number(options.maxStoreBytes);
1171
+ const maximumBytes = Number.isSafeInteger(configuredMaximum) && configuredMaximum >= MIN_CONFIGURED_STORE_BYTES
1172
+ ? configuredMaximum
1173
+ : MAX_STORE_BYTES;
1174
+ const compactionTriggerBytes = Math.floor(maximumBytes * 0.75);
1175
+ const compactionTargetBytes = Math.floor(maximumBytes * 0.5);
1176
+ if (!storePath) throw new HelloLynkLocalMessagingError("local_message_store_path_required");
1177
+ const protection = normalizeMessageDataProtection(options.dataProtector, profileId);
1178
+ const localHistoryPath = `${storePath}.local-history.json`;
1179
+ const historyProtection = protection ? { ...protection, mainContext: Object.freeze({
1180
+ profileId, assetKind: "messages", logicalPath: `messages/${profileId}.json.local-history.json`,
1181
+ }) } : null;
1182
+ let cachedHistory = null;
1183
+
1184
+ function emptyHistory() {
1185
+ return { contractId: LOCAL_HISTORY_CONTRACT, schemaVersion: 1, profileId,
1186
+ cleanupPending: false, deleted: [], hidden: [] };
1187
+ }
1188
+
1189
+ async function readHistory() {
1190
+ await assertMessageStoreAccess(localHistoryPath, historyProtection, { create: false });
1191
+ let metadata;
1192
+ try { metadata = await lstat(localHistoryPath); }
1193
+ catch (error) { if (error?.code === "ENOENT") { cachedHistory = null; return emptyHistory(); } throw error; }
1194
+ if (!metadata.isFile() || metadata.isSymbolicLink()
1195
+ || metadata.size > protectedStoreMaximum(MAX_LOCAL_HISTORY_BYTES, historyProtection)) throw localHistoryFailure("index_invalid");
1196
+ const signature = `${metadata.dev}:${metadata.ino}:${metadata.size}:${metadata.mtimeMs}:${metadata.ctimeMs}`;
1197
+ if (cachedHistory?.signature === signature) return structuredClone(cachedHistory.value);
1198
+ const handle = await open(localHistoryPath, "r");
1199
+ let value;
1200
+ try {
1201
+ const opened = await handle.stat();
1202
+ if (!opened.isFile() || (process.platform !== "win32" && opened.dev !== metadata.dev) || opened.ino !== metadata.ino
1203
+ || opened.size !== metadata.size) throw localHistoryFailure("index_invalid");
1204
+ const stored = JSON.parse(await handle.readFile("utf8"));
1205
+ value = historyProtection ? await historyProtection.protector.openJson(stored, historyProtection.mainContext) : stored;
1206
+ } catch (cause) { throw localHistoryFailure("index_invalid"); }
1207
+ finally { await handle.close(); }
1208
+ if (!hasExactFields(value, ["cleanupPending", "contractId", "deleted", "hidden", "profileId", "schemaVersion"])
1209
+ || value.contractId !== LOCAL_HISTORY_CONTRACT || value.schemaVersion !== 1 || value.profileId !== profileId
1210
+ || typeof value.cleanupPending !== "boolean" || !Array.isArray(value.deleted) || !Array.isArray(value.hidden)
1211
+ || storeByteLength(value) > MAX_LOCAL_HISTORY_BYTES) throw localHistoryFailure("index_invalid");
1212
+ const ids = new Set();
1213
+ for (const item of value.deleted) {
1214
+ validateCurrentMessage(item);
1215
+ if (item.messageType !== LOCAL_DELETED_MESSAGE_TYPE || item.text !== "" || item.e2ee != null
1216
+ || ids.has(item.localMessageId)) throw localHistoryFailure("index_invalid");
1217
+ ids.add(item.localMessageId);
1218
+ }
1219
+ const hiddenKeys = new Set();
1220
+ for (const item of value.hidden) {
1221
+ if (!hasExactFields(item, ["delegateId", "knownMessageIds", "principalId", "region", "threadId"])) throw localHistoryFailure("index_invalid");
1222
+ const scope = localHistoryScope(item);
1223
+ safeId(item.threadId, "local_history_index_invalid");
1224
+ if (!Array.isArray(item.knownMessageIds)) throw localHistoryFailure("index_invalid");
1225
+ for (const id of item.knownMessageIds) safeId(id, "local_history_index_invalid");
1226
+ const key = `${localHistoryScopeKey(scope)}\0${item.threadId}`;
1227
+ if (hiddenKeys.has(key)) throw localHistoryFailure("index_invalid");
1228
+ hiddenKeys.add(key);
1229
+ }
1230
+ cachedHistory = { signature, value: structuredClone(value) };
1231
+ return value;
1232
+ }
1233
+
1234
+ async function writeHistory(index) {
1235
+ cachedHistory = null;
1236
+ await atomicWriteStore(localHistoryPath, index, MAX_LOCAL_HISTORY_BYTES, historyProtection);
1237
+ }
1238
+
1239
+ function mergeMessageCopies(store, archived) {
1240
+ const byId = new Map();
1241
+ for (const item of [...archived, ...store.messages]) {
1242
+ // A compacted tombstone always wins over a stale duplicate body.
1243
+ if (byId.get(item.localMessageId)?.messageType !== LOCAL_DELETED_MESSAGE_TYPE) byId.set(item.localMessageId, item);
1244
+ }
1245
+ return [...byId.values()];
1246
+ }
1247
+
1248
+ async function applyHistoryCleanup(store, index, archived = null) {
1249
+ if (!index.cleanupPending) return store;
1250
+ const deletions = new Map(index.deleted.map((item) => [item.localMessageId, item]));
1251
+ const archive = archived || await scanArchive(archivePath, profileId, null, protection);
1252
+ const scrub = (items) => items.map((item) => deletions.get(item.localMessageId) || item);
1253
+ const nextArchived = scrub(archive);
1254
+ if (JSON.stringify(nextArchived) !== JSON.stringify(archive)) {
1255
+ await replaceArchiveMessages(archivePath, profileId, nextArchived, protection);
1256
+ }
1257
+ await options.localHistoryFault?.("archive-cleaned");
1258
+ const nextMessages = scrub(store.messages);
1259
+ if (JSON.stringify(nextMessages) !== JSON.stringify(store.messages)) {
1260
+ store.messages = nextMessages;
1261
+ await atomicWriteStore(storePath, store, maximumBytes, protection);
1262
+ }
1263
+ await options.localHistoryFault?.("main-cleaned");
1264
+ index.cleanupPending = false;
1265
+ await writeHistory(index);
1266
+ return store;
1267
+ }
1268
+
1269
+ async function readCurrentStore() {
1270
+ const store = await readStore(storePath, profileId, maximumBytes, protection);
1271
+ const index = await readHistory();
1272
+ return applyHistoryCleanup(store, index);
1273
+ }
1274
+
1275
+ async function readAdmissionStore() {
1276
+ const store = await readCurrentStore();
1277
+ const index = await readHistory();
1278
+ const archived = await scanArchive(archivePath, profileId, null, protection);
1279
+ if ([...index.deleted, ...archived].some((item) => item.receiptSeq > (store.receiptHighWater ?? 0))) {
1280
+ throw new HelloLynkLocalMessagingError("local_message_receipt_sequence_invalid");
1281
+ }
1282
+ return store;
1283
+ }
1284
+
1285
+ async function restoreChangedConversations(index, messages) {
1286
+ if (index.hidden.length === 0 || messages.length === 0) return;
1287
+ const before = index.hidden.length;
1288
+ const keyFor = (item) => `${localHistoryScopeKey(item)}\0${item.threadId}`;
1289
+ const hidden = new Map(index.hidden.map((item) => [keyFor(item), new Set(item.knownMessageIds)]));
1290
+ for (const message of messages) {
1291
+ let binding;
1292
+ try { binding = getMessageConversationBinding(message) || getOutgoingConversationIntent(message); }
1293
+ catch { continue; }
1294
+ if (!binding) continue;
1295
+ const key = `${localHistoryScopeKey({ region: binding.region, principalId: binding.localPrincipalId,
1296
+ delegateId: binding.localDelegateId })}\0${instanceConversationKey(binding)}`;
1297
+ const known = hidden.get(key);
1298
+ if (known && !known.has(message.localMessageId)) hidden.delete(key);
1299
+ }
1300
+ index.hidden = index.hidden.filter((item) => hidden.has(keyFor(item)));
1301
+ if (index.hidden.length !== before) await writeHistory(index);
1302
+ }
1303
+
1304
+ async function localHistoryMutation(input, operation) {
1305
+ const scope = localHistoryScope(input);
1306
+ const threadId = operation === "delete" ? null : safeId(input.threadId, "local_history_thread_invalid");
1307
+ const requestedIds = operation === "delete" ? [...new Set((input.localMessageIds || []).map(
1308
+ (id) => safeId(id, "local_history_message_invalid")))] : [];
1309
+ if (operation === "delete" && (requestedIds.length === 0 || requestedIds.length > 500)) throw localHistoryFailure("message_invalid");
1310
+ return withPathMutationQueue(storePath, async () => {
1311
+ const store = await readCurrentStore();
1312
+ const index = await readHistory();
1313
+ const archived = await scanArchive(archivePath, profileId, null, protection);
1314
+ const all = mergeMessageCopies(store, archived);
1315
+ const selected = operation === "delete" ? requestedIds.map((id) => {
1316
+ const message = all.find((item) => item.localMessageId === id)
1317
+ || index.deleted.find((item) => item.localMessageId === id);
1318
+ if (!message || message.direction !== "outgoing" || message.senderPrincipalId !== scope.principalId
1319
+ || message.senderDelegateId !== scope.delegateId || !localHistoryBinding(message, scope)) throw localHistoryFailure("record_not_found");
1320
+ if (!localHistorySent(message)) throw localHistoryFailure("outgoing_protected");
1321
+ return message;
1322
+ }) : all.filter((message) => localHistoryThreadId(message, scope) === threadId);
1323
+ const visible = selected.filter((message) => message.messageType !== LOCAL_DELETED_MESSAGE_TYPE);
1324
+ const retainedUnreadCount = visible.filter(localHistoryUnread).length;
1325
+ const retainedOutgoingCount = visible.filter((message) => message.direction === "outgoing" && !localHistorySent(message)).length;
1326
+ const sameHidden = (item) => localHistoryScopeKey(item) === localHistoryScopeKey(scope) && item.threadId === threadId;
1327
+ if (operation === "hide") {
1328
+ if (retainedUnreadCount) throw localHistoryFailure("unread_protected");
1329
+ if (retainedOutgoingCount) throw localHistoryFailure("outgoing_protected");
1330
+ index.hidden = index.hidden.filter((item) => !sameHidden(item));
1331
+ index.hidden.push({ ...scope, threadId, knownMessageIds: selected.map((item) => item.localMessageId).sort() });
1332
+ await writeHistory(index);
1333
+ return {};
1334
+ }
1335
+ if (operation === "restore") {
1336
+ if (index.hidden.some(sameHidden)) { index.hidden = index.hidden.filter((item) => !sameHidden(item)); await writeHistory(index); }
1337
+ return {};
1338
+ }
1339
+ const deletable = visible.filter((message) => message.direction === "incoming" ? !localHistoryUnread(message) : localHistorySent(message));
1340
+ if (deletable.length) {
1341
+ const tombstones = new Map(index.deleted.map((item) => [item.localMessageId, item]));
1342
+ for (const message of deletable) tombstones.set(message.localMessageId, localHistoryTombstone(message, scope));
1343
+ index.deleted = [...tombstones.values()];
1344
+ index.cleanupPending = true;
1345
+ // This durable intent is the visibility commit. Main/archive redaction
1346
+ // is recoverable, not a claimed multi-file atomic transaction. Each
1347
+ // body replacement preserves its ID and terminal evidence atomically.
1348
+ await writeHistory(index);
1349
+ await options.localHistoryFault?.("intent-committed");
1350
+ await applyHistoryCleanup(store, index, archived);
1351
+ }
1352
+ return { deletedCount: deletable.length, retainedUnreadCount, retainedOutgoingCount };
1353
+ });
1354
+ }
1355
+
1356
+ async function mutate(callback, mutationOptions = {}) {
1357
+ return withPathMutationQueue(storePath, async () => {
1358
+ const store = await readCurrentStore();
1359
+ const initialIds = new Set(store.messages.map((item) => item.localMessageId));
1360
+ const archived = mutationOptions.scanArchive === true
1361
+ ? await scanArchive(archivePath, profileId, null, protection)
1362
+ : [];
1363
+ const result = await callback(store, archived);
1364
+ await compactFinalMessages({
1365
+ store,
1366
+ archivePath,
1367
+ profileId,
1368
+ triggerBytes: compactionTriggerBytes,
1369
+ targetBytes: compactionTargetBytes,
1370
+ protection,
1371
+ });
1372
+ await atomicWriteStore(storePath, store, maximumBytes, protection);
1373
+ const added = store.messages.filter((item) => !initialIds.has(item.localMessageId));
1374
+ if (added.length) await restoreChangedConversations(await readHistory(), added);
1375
+ return result;
1376
+ });
1377
+ }
1378
+
1379
+ return {
1380
+ path: storePath,
1381
+ archivePath,
1382
+ localHistoryPath,
1383
+ readLegacyHistoryForMigration: readHistory,
1384
+ async getReceiptHighWater() {
1385
+ return withPathMutationQueue(storePath, async () => (await readAdmissionStore()).receiptHighWater ?? 0);
1386
+ },
1387
+ async getInboxReaderBoundary(adapterId, delegateId) {
1388
+ inboxReaderId(adapterId);
1389
+ inboxReaderId(delegateId);
1390
+ return withPathMutationQueue(storePath, async () => {
1391
+ const store = await readAdmissionStore();
1392
+ return store.inboxReaders?.find((item) => item.adapterId === adapterId && item.delegateId === delegateId)?.afterSeq ?? null;
1393
+ });
1394
+ },
1395
+ async activateInboxReader(adapterId, delegateId) {
1396
+ inboxReaderId(adapterId);
1397
+ inboxReaderId(delegateId);
1398
+ return withPathMutationQueue(storePath, async () => {
1399
+ const store = await readAdmissionStore();
1400
+ const existing = store.inboxReaders?.find((item) => item.adapterId === adapterId && item.delegateId === delegateId);
1401
+ if (existing) return existing.afterSeq;
1402
+ if ((store.inboxReaders?.length ?? 0) >= 1024) {
1403
+ throw new HelloLynkLocalMessagingError("local_inbox_reader_capacity_exceeded");
1404
+ }
1405
+ store.receiptHighWater ??= 0;
1406
+ store.inboxReaders = [...(store.inboxReaders || []), { adapterId, delegateId, afterSeq: store.receiptHighWater }];
1407
+ // The durable first activation shares the incoming-record order. Reopen
1408
+ // and repeated activation return this same boundary, preserving backlog.
1409
+ await atomicWriteStore(storePath, store, maximumBytes, protection);
1410
+ return store.receiptHighWater;
1411
+ });
1412
+ },
1413
+ async listInboxClaimIds(delegateId) {
1414
+ inboxReaderId(delegateId);
1415
+ return withPathMutationQueue(storePath, async () => (await readAdmissionStore()).inboxClaims
1416
+ ?.filter((item) => item.delegateId === delegateId).map((item) => item.localMessageId) ?? []);
1417
+ },
1418
+ async claimInboxMessage(input, authorize) {
1419
+ return inboxClaimMutation(input, authorize, false);
1420
+ },
1421
+ async finishInboxClaim(input, authorize) {
1422
+ return inboxClaimMutation(input, authorize, true);
1423
+ },
1424
+ deleteSentLocal: (input) => localHistoryMutation(input, "delete"),
1425
+ hideLocalConversation: (input) => localHistoryMutation(input, "hide"),
1426
+ restoreLocalConversation: (input) => localHistoryMutation(input, "restore"),
1427
+ clearLocalConversation: (input) => localHistoryMutation(input, "clear"),
1428
+ async readWorkbenchState(input) {
1429
+ const scope = localHistoryScope(input);
1430
+ return withPathMutationQueue(storePath, async () => {
1431
+ const store = await readCurrentStore();
1432
+ const archived = await scanArchive(archivePath, profileId, null, protection);
1433
+ const index = await readHistory();
1434
+ const all = mergeMessageCopies(store, archived);
1435
+ await restoreChangedConversations(index, all);
1436
+ const deletedIds = new Set(index.deleted.map((item) => item.localMessageId));
1437
+ return {
1438
+ messages: structuredClone(all.filter((item) => item.messageType !== LOCAL_DELETED_MESSAGE_TYPE && !deletedIds.has(item.localMessageId))),
1439
+ hiddenThreadIds: index.hidden.filter((item) => localHistoryScopeKey(item) === localHistoryScopeKey(scope)).map((item) => item.threadId),
1440
+ };
1441
+ });
1442
+ },
1443
+ async addOutgoing({ recipientHelloLynkId, clientMessageId, text, senderPrincipalId, senderDelegateId, e2ee, conversationIntent, e2eePreparation, replyToClientMessageId, targetConversationId, resendSourceClientMessageId }) {
1444
+ if (targetConversationId != null) throw new HelloLynkLocalMessagingError("native_target_requires_indexed_store");
1445
+ if (replyToClientMessageId != null || resendSourceClientMessageId != null || e2ee?.envelope?.protected?.replyToClientMessageId != null) throw new HelloLynkLocalMessagingError("reply_correlation_requires_indexed_store");
1446
+ const acceptedRecipient = safeId(recipientHelloLynkId, "recipient_hellolynk_id_invalid");
1447
+ const acceptedClientMessageId = safeId(clientMessageId, "client_message_id_invalid");
1448
+ const acceptedText = safeText(text);
1449
+ const acceptedPrincipalId = safeId(senderPrincipalId, "sender_principal_id_invalid");
1450
+ const acceptedDelegateId = safeId(senderDelegateId, "sender_delegate_id_invalid");
1451
+ const acceptedE2ee = normalizeStoredE2ee(e2ee);
1452
+ const provenance = provenanceFromStoredE2ee(
1453
+ acceptedE2ee,
1454
+ acceptedPrincipalId,
1455
+ acceptedDelegateId,
1456
+ );
1457
+ return mutate(async (store, archived) => {
1458
+ const index = await readHistory();
1459
+ if ([...store.messages, ...archived, ...index.deleted].some((item) => item.clientMessageId === acceptedClientMessageId)) {
1460
+ throw new HelloLynkLocalMessagingError("client_message_id_exists");
1461
+ }
1462
+ const at = new Date(now()).toISOString();
1463
+ const message = {
1464
+ localMessageId: `local_message_${crypto.randomUUID()}`,
1465
+ clientMessageId: acceptedClientMessageId,
1466
+ direction: "outgoing",
1467
+ peerHelloLynkId: acceptedRecipient,
1468
+ senderPrincipalId: acceptedPrincipalId,
1469
+ senderDelegateId: acceptedDelegateId,
1470
+ authorizationScope: provenance.authorizationScope,
1471
+ intentSource: provenance.intentSource,
1472
+ messageType: "text",
1473
+ text: acceptedText,
1474
+ transportSecurity: acceptedE2ee ? E2EE_TRANSPORT_SECURITY : "plaintext-v0.1.0",
1475
+ e2ee: acceptedE2ee,
1476
+ ...(e2eePreparation ? {
1477
+ e2eePreparation: structuredClone(e2eePreparation),
1478
+ deliveryAttemptPid: process.pid,
1479
+ deliveryAttemptInstance: PROCESS_INSTANCE_ID,
1480
+ deliveryAttemptStartedAt: at,
1481
+ } : {}),
1482
+ conversationIntent: normalizeInstanceConversationBinding(conversationIntent),
1483
+ status: "pending",
1484
+ attempts: 0,
1485
+ nextRetryAt: null,
1486
+ lastErrorCode: null,
1487
+ createdAt: at,
1488
+ updatedAt: at,
1489
+ };
1490
+ message.conversationBinding = getMessageConversationBinding(message);
1491
+ store.messages.push(message);
1492
+ return structuredClone(message);
1493
+ }, { scanArchive: true });
1494
+ },
1495
+ async updateOutgoing(clientMessageId, patch) {
1496
+ const acceptedId = safeId(clientMessageId, "client_message_id_invalid");
1497
+ return withPathMutationQueue(storePath, async () => {
1498
+ const store = await readCurrentStore();
1499
+ let archived = null;
1500
+ let archivedIndex = -1;
1501
+ let message = store.messages.find((item) => (
1502
+ item.direction === "outgoing" && item.clientMessageId === acceptedId
1503
+ ));
1504
+ if (!message) {
1505
+ archived = await scanArchive(archivePath, profileId, null, protection);
1506
+ archivedIndex = findLatestIndex(archived, (item) => (
1507
+ item.direction === "outgoing" && item.clientMessageId === acceptedId
1508
+ ));
1509
+ if (archivedIndex >= 0) message = archived[archivedIndex];
1510
+ }
1511
+ if (!message) throw new HelloLynkLocalMessagingError("local_outgoing_message_not_found");
1512
+ if (message.messageType === LOCAL_DELETED_MESSAGE_TYPE) throw localHistoryFailure("outgoing_protected");
1513
+ if (patch.preparationOnly && (message.status !== "pending" || message.attempts !== 0)) {
1514
+ return structuredClone(message);
1515
+ }
1516
+ if (patch.beforeTransportClaim && (
1517
+ message.status !== "delivery_state_unknown" || message.lastErrorCode !== "awaiting_relay_result"
1518
+ || message.deliveryAttemptPid !== process.pid || message.deliveryAttemptInstance !== PROCESS_INSTANCE_ID
1519
+ || message.attempts !== patch.beforeTransportClaim.attempts
1520
+ || message.deliveryAttemptStartedAt !== patch.beforeTransportClaim.startedAt
1521
+ )) {
1522
+ throw new HelloLynkLocalMessagingError("local_outgoing_attempt_mismatch", { httpStatus: 409 });
1523
+ }
1524
+ const nextStatus = String(patch.status || message.status);
1525
+ if (archived && (message.status !== "failed_permanent" || nextStatus !== "failed_permanent")) {
1526
+ throw new HelloLynkLocalMessagingError("local_outgoing_message_not_retryable", { httpStatus: 409 });
1527
+ }
1528
+ message.status = nextStatus;
1529
+ message.attempts = Number(message.attempts || 0) + (patch.incrementAttempts === false ? 0 : 1);
1530
+ message.lastErrorCode = patch.lastErrorCode == null ? null : String(patch.lastErrorCode);
1531
+ message.deliveryAttemptPid = null;
1532
+ message.deliveryAttemptInstance = null;
1533
+ message.deliveryAttemptStartedAt = null;
1534
+ if (["recipient_offline", "failed_retryable"].includes(nextStatus)) {
1535
+ const retryDelayMs = Math.min(1_000 * (2 ** Math.min(Math.max(message.attempts - 1, 0), 6)), 60_000);
1536
+ message.nextRetryAt = new Date(now() + retryDelayMs).toISOString();
1537
+ } else {
1538
+ message.nextRetryAt = null;
1539
+ }
1540
+ message.updatedAt = new Date(now()).toISOString();
1541
+ if (archived) {
1542
+ archived[archivedIndex] = message;
1543
+ await replaceArchiveMessages(archivePath, profileId, archived, protection);
1544
+ return structuredClone(message);
1545
+ }
1546
+ await compactFinalMessages({
1547
+ store,
1548
+ archivePath,
1549
+ profileId,
1550
+ triggerBytes: compactionTriggerBytes,
1551
+ targetBytes: compactionTargetBytes,
1552
+ protection,
1553
+ });
1554
+ await atomicWriteStore(storePath, store, maximumBytes, protection);
1555
+ return structuredClone(message);
1556
+ });
1557
+ },
1558
+ async attachOutgoingE2ee(clientMessageId, e2ee) {
1559
+ const acceptedId = safeId(clientMessageId, "client_message_id_invalid");
1560
+ const acceptedE2ee = normalizeStoredE2ee(e2ee);
1561
+ if (!acceptedE2ee) throw new HelloLynkLocalMessagingError("e2ee_required");
1562
+ return withPathMutationQueue(storePath, async () => {
1563
+ const store = await readCurrentStore();
1564
+ const message = store.messages.find((item) => (
1565
+ item.direction === "outgoing" && item.clientMessageId === acceptedId
1566
+ ));
1567
+ if (!message) throw new HelloLynkLocalMessagingError("local_outgoing_message_not_found");
1568
+ if (message.status !== "pending" || message.e2ee != null) {
1569
+ throw new HelloLynkLocalMessagingError("local_outgoing_e2ee_attach_conflict", { httpStatus: 409 });
1570
+ }
1571
+ const provenance = provenanceFromStoredE2ee(
1572
+ acceptedE2ee,
1573
+ message.senderPrincipalId,
1574
+ message.senderDelegateId,
1575
+ );
1576
+ message.e2ee = acceptedE2ee;
1577
+ message.conversationBinding = getMessageConversationBinding(message);
1578
+ message.authorizationScope = provenance.authorizationScope;
1579
+ message.intentSource = provenance.intentSource;
1580
+ message.transportSecurity = E2EE_TRANSPORT_SECURITY;
1581
+ message.updatedAt = new Date(now()).toISOString();
1582
+ await atomicWriteStore(storePath, store, maximumBytes, protection);
1583
+ return structuredClone(message);
1584
+ });
1585
+ },
1586
+ async claimOutgoingForPreparation(clientMessageId, expectedIdentity) {
1587
+ const acceptedId = safeId(clientMessageId, "client_message_id_invalid");
1588
+ return mutate((store) => {
1589
+ const message = store.messages.find((item) => item.direction === "outgoing" && item.clientMessageId === acceptedId);
1590
+ if (!message) throw new HelloLynkLocalMessagingError("local_outgoing_message_not_found");
1591
+ if (message.senderPrincipalId !== expectedIdentity.senderPrincipalId || message.senderDelegateId !== expectedIdentity.senderDelegateId) {
1592
+ throw new HelloLynkLocalMessagingError("local_outgoing_identity_mismatch", { httpStatus: 409 });
1593
+ }
1594
+ if (!MANUAL_RETRYABLE_OUTGOING_STATUSES.has(message.status) || message.e2ee || message.attempts !== 0) {
1595
+ throw new HelloLynkLocalMessagingError("local_outgoing_message_not_retryable", { httpStatus: 409 });
1596
+ }
1597
+ if (!message.e2eePreparation) throw new HelloLynkLocalMessagingError("e2ee_preparation_context_missing", { httpStatus: 409 });
1598
+ message.status = "pending";
1599
+ message.nextRetryAt = null;
1600
+ message.lastErrorCode = null;
1601
+ message.deliveryAttemptPid = process.pid;
1602
+ message.deliveryAttemptInstance = PROCESS_INSTANCE_ID;
1603
+ message.deliveryAttemptStartedAt = new Date(now()).toISOString();
1604
+ message.updatedAt = message.deliveryAttemptStartedAt;
1605
+ return structuredClone(message);
1606
+ });
1607
+ },
1608
+ async claimOutgoingForAttempt(clientMessageId, expectedIdentity = {}) {
1609
+ const acceptedId = safeId(clientMessageId, "client_message_id_invalid");
1610
+ const expectedPrincipalId = safeId(expectedIdentity.senderPrincipalId, "sender_principal_id_invalid");
1611
+ const expectedDelegateId = safeId(expectedIdentity.senderDelegateId, "sender_delegate_id_invalid");
1612
+ return withPathMutationQueue(storePath, async () => {
1613
+ const store = await readCurrentStore();
1614
+ let claimedFromArchive = false;
1615
+ let message = store.messages.find((item) => (
1616
+ item.direction === "outgoing" && item.clientMessageId === acceptedId
1617
+ ));
1618
+ if (!message) {
1619
+ const archived = await scanArchive(archivePath, profileId, null, protection);
1620
+ const archivedIndex = findLatestIndex(archived, (item) => (
1621
+ item.direction === "outgoing" && item.clientMessageId === acceptedId
1622
+ ));
1623
+ if (archivedIndex >= 0) {
1624
+ message = structuredClone(archived[archivedIndex]);
1625
+ claimedFromArchive = true;
1626
+ }
1627
+ }
1628
+ if (!message) throw new HelloLynkLocalMessagingError("local_outgoing_message_not_found");
1629
+ if (!message.senderPrincipalId || !message.senderDelegateId) {
1630
+ throw new HelloLynkLocalMessagingError("local_outgoing_identity_unbound", { httpStatus: 409 });
1631
+ }
1632
+ if (
1633
+ message.senderPrincipalId !== expectedPrincipalId ||
1634
+ message.senderDelegateId !== expectedDelegateId
1635
+ ) {
1636
+ throw new HelloLynkLocalMessagingError("local_outgoing_identity_mismatch", { httpStatus: 409 });
1637
+ }
1638
+ if (!CLAIMABLE_OUTGOING_STATUSES.has(message.status)) {
1639
+ throw new HelloLynkLocalMessagingError("local_outgoing_message_not_retryable", { httpStatus: 409 });
1640
+ }
1641
+ if (claimedFromArchive) store.messages.push(message);
1642
+ message.status = "delivery_state_unknown";
1643
+ message.attempts = Number(message.attempts || 0) + 1;
1644
+ message.lastErrorCode = "awaiting_relay_result";
1645
+ message.deliveryAttemptPid = process.pid;
1646
+ message.deliveryAttemptInstance = PROCESS_INSTANCE_ID;
1647
+ message.deliveryAttemptStartedAt = new Date(now()).toISOString();
1648
+ message.updatedAt = new Date(now()).toISOString();
1649
+ await compactFinalMessages({
1650
+ store,
1651
+ archivePath,
1652
+ profileId,
1653
+ triggerBytes: compactionTriggerBytes,
1654
+ targetBytes: compactionTargetBytes,
1655
+ protection,
1656
+ });
1657
+ await atomicWriteStore(storePath, store, maximumBytes, protection);
1658
+ const latestArchived = await scanArchive(archivePath, profileId, null, protection);
1659
+ if (latestArchived.some((item) => (
1660
+ item.direction === "outgoing" &&
1661
+ item.clientMessageId === acceptedId &&
1662
+ item.localMessageId === message.localMessageId
1663
+ ))) {
1664
+ // The active unknown claim is durable first, so cleanup failure remains fail-closed before transport.
1665
+ await replaceArchiveMessages(
1666
+ archivePath,
1667
+ profileId,
1668
+ latestArchived.filter((item) => (
1669
+ item.direction !== "outgoing" ||
1670
+ item.clientMessageId !== acceptedId ||
1671
+ item.localMessageId !== message.localMessageId
1672
+ )),
1673
+ protection,
1674
+ );
1675
+ }
1676
+ return structuredClone(message);
1677
+ });
1678
+ },
1679
+ async recoverArchivedOutgoingRetryClaim(options = {}) {
1680
+ const acceptedId = safeId(options.clientMessageId, "client_message_id_invalid");
1681
+ return withPathMutationQueue(storePath, async () => {
1682
+ const store = await readCurrentStore();
1683
+ const active = store.messages.find((item) => (
1684
+ item.direction === "outgoing" &&
1685
+ item.clientMessageId === acceptedId &&
1686
+ (
1687
+ (item.status === "delivery_state_unknown" && item.lastErrorCode === "awaiting_relay_result") ||
1688
+ (
1689
+ item.status === "delivery_state_unconfirmed" &&
1690
+ item.lastErrorCode === "e2ee_delivery_attempt_interrupted"
1691
+ )
1692
+ )
1693
+ ));
1694
+ if (!active) return null;
1695
+ const activeUnknown = (
1696
+ active.status === "delivery_state_unknown" &&
1697
+ active.lastErrorCode === "awaiting_relay_result"
1698
+ );
1699
+ const interruptedUnconfirmed = (
1700
+ active.status === "delivery_state_unconfirmed" &&
1701
+ active.lastErrorCode === "e2ee_delivery_attempt_interrupted"
1702
+ );
1703
+ if (activeUnknown && deliveryAttemptHasLiveOwner(active)) return null;
1704
+ if (!activeUnknown && !interruptedUnconfirmed) return null;
1705
+ const archived = await scanArchive(archivePath, profileId, null, protection);
1706
+ const retainedIndex = findLatestIndex(archived, (item) => (
1707
+ item.direction === "outgoing" &&
1708
+ item.clientMessageId === acceptedId &&
1709
+ item.localMessageId === active.localMessageId
1710
+ ));
1711
+ const retainedPermanent = retainedIndex >= 0 ? archived[retainedIndex] : null;
1712
+ if (retainedPermanent?.status !== "failed_permanent") return null;
1713
+ store.messages = store.messages.filter((item) => !(
1714
+ item.direction === "outgoing" &&
1715
+ item.clientMessageId === acceptedId &&
1716
+ item.localMessageId === active.localMessageId
1717
+ ));
1718
+ await atomicWriteStore(storePath, store, maximumBytes, protection);
1719
+ return structuredClone(retainedPermanent);
1720
+ });
1721
+ },
1722
+ async recoverAbandonedE2eeOutgoingAttempts(options = {}) {
1723
+ const clientMessageId = options.clientMessageId == null
1724
+ ? null
1725
+ : safeId(options.clientMessageId, "client_message_id_invalid");
1726
+ return mutate((store) => recoverAbandonedE2eeOutgoingAttempts(store, now, clientMessageId));
1727
+ },
1728
+ async addIncoming(event) {
1729
+ if (event?.targetConversationId != null) throw new HelloLynkLocalMessagingError("native_target_requires_indexed_store");
1730
+ if (event?.replyToClientMessageId != null) throw new HelloLynkLocalMessagingError("reply_correlation_requires_indexed_store");
1731
+ const eventId = safeId(event?.eventId, "relay_event_id_invalid");
1732
+ const clientMessageId = safeId(event?.clientMessageId, "client_message_id_invalid");
1733
+ const senderPrincipalId = safeId(event?.senderPrincipalId, "sender_principal_id_invalid");
1734
+ const senderDelegateId = safeId(event?.senderDelegateId, "sender_delegate_id_invalid");
1735
+ const provenance = normalizeMessageProvenance(
1736
+ event?.authorizationScope,
1737
+ event?.intentSource,
1738
+ {
1739
+ required: event?.envelopeDigest != null,
1740
+ code: "message_provenance_invalid",
1741
+ },
1742
+ );
1743
+ const senderHelloLynkId = event?.senderHelloLynkId == null
1744
+ ? null
1745
+ : safeId(event.senderHelloLynkId, "sender_hellolynk_id_invalid");
1746
+ const senderPrincipalDisplayName = optionalVerifiedDisplayName(
1747
+ event?.senderPrincipalDisplayName,
1748
+ "sender_principal_display_name_invalid",
1749
+ );
1750
+ const senderDelegateDisplayName = optionalVerifiedDisplayName(
1751
+ event?.senderDelegateDisplayName,
1752
+ "sender_delegate_display_name_invalid",
1753
+ );
1754
+ const text = safeText(event?.text);
1755
+ const envelopeDigest = event?.envelopeDigest == null
1756
+ ? null
1757
+ : safeSha256(event.envelopeDigest);
1758
+ const senderIdentityKeyId = event?.senderIdentityKeyId == null
1759
+ ? null
1760
+ : safeId(event.senderIdentityKeyId, "e2ee_sender_identity_key_id_invalid");
1761
+ const recipientIdentityKeyId = event?.recipientIdentityKeyId == null
1762
+ ? null
1763
+ : safeId(event.recipientIdentityKeyId, "e2ee_recipient_identity_key_id_invalid");
1764
+ const conversationBinding = normalizeInstanceConversationBinding(event?.conversationBinding);
1765
+ if (conversationBinding) {
1766
+ if (!envelopeDigest) throw new HelloLynkLocalMessagingError("message_provenance_invalid");
1767
+ getMessageConversationBinding({
1768
+ ...event, direction: "incoming", conversationBinding, peerHelloLynkId: senderHelloLynkId,
1769
+ });
1770
+ }
1771
+ return mutate(async (store, archived) => {
1772
+ const index = await readHistory();
1773
+ const existing = [...index.deleted, ...store.messages, ...archived].find((item) => (
1774
+ item.direction === "incoming" && (
1775
+ item.eventId === eventId ||
1776
+ (item.peerPrincipalId === senderPrincipalId && item.clientMessageId === clientMessageId)
1777
+ )
1778
+ ));
1779
+ if (existing) {
1780
+ if (existing.messageType === LOCAL_DELETED_MESSAGE_TYPE) {
1781
+ if (existing.direction !== "incoming" || existing.clientMessageId !== clientMessageId
1782
+ || existing.peerPrincipalId !== senderPrincipalId || existing.senderDelegateId !== senderDelegateId
1783
+ || (existing.peerHelloLynkId || null) !== senderHelloLynkId
1784
+ || (existing.envelopeDigest || null) !== envelopeDigest
1785
+ || (existing.senderIdentityKeyId || null) !== senderIdentityKeyId
1786
+ || (existing.recipientIdentityKeyId || null) !== recipientIdentityKeyId
1787
+ || !existing.conversationBinding || !conversationBinding
1788
+ || instanceConversationKey(existing.conversationBinding) !== instanceConversationKey(conversationBinding)) {
1789
+ throw new HelloLynkLocalMessagingError("local_incoming_message_conflict", { httpStatus: 409 });
1790
+ }
1791
+ return { message: structuredClone(existing), duplicate: true };
1792
+ }
1793
+ const legacyProvenanceMissing = !Object.hasOwn(existing, "senderPrincipalId")
1794
+ && !Object.hasOwn(existing, "authorizationScope")
1795
+ && !Object.hasOwn(existing, "intentSource");
1796
+ const exactReplay = (
1797
+ existing.clientMessageId === clientMessageId &&
1798
+ existing.peerPrincipalId === senderPrincipalId &&
1799
+ existing.senderDelegateId === senderDelegateId &&
1800
+ (existing.peerHelloLynkId || null) === senderHelloLynkId &&
1801
+ existing.messageType === "text" &&
1802
+ existing.text === text &&
1803
+ (existing.envelopeDigest || null) === envelopeDigest &&
1804
+ (existing.senderIdentityKeyId || null) === senderIdentityKeyId &&
1805
+ (existing.recipientIdentityKeyId || null) === recipientIdentityKeyId &&
1806
+ ((!existing.conversationBinding)
1807
+ || (existing.conversationBinding && conversationBinding
1808
+ && instanceConversationKey(existing.conversationBinding) === instanceConversationKey(conversationBinding))) &&
1809
+ (legacyProvenanceMissing || (
1810
+ existing.senderPrincipalId === senderPrincipalId
1811
+ && (existing.authorizationScope ?? null) === provenance.authorizationScope
1812
+ && (existing.intentSource ?? null) === provenance.intentSource
1813
+ ))
1814
+ );
1815
+ if (!exactReplay) {
1816
+ throw new HelloLynkLocalMessagingError("local_incoming_message_conflict", { httpStatus: 409 });
1817
+ }
1818
+ return { message: structuredClone(existing), duplicate: true };
1819
+ }
1820
+ const highWater = store.receiptHighWater ?? 0;
1821
+ if ([...archived, ...index.deleted].some((item) => item.receiptSeq > highWater)
1822
+ || highWater >= Number.MAX_SAFE_INTEGER) {
1823
+ throw new HelloLynkLocalMessagingError("local_message_receipt_sequence_invalid");
1824
+ }
1825
+ const receiptSeq = highWater + 1;
1826
+ store.receiptHighWater = receiptSeq;
1827
+ const at = new Date(now()).toISOString();
1828
+ const message = {
1829
+ localMessageId: `local_message_${crypto.randomUUID()}`,
1830
+ eventId,
1831
+ receiptSeq,
1832
+ clientMessageId,
1833
+ direction: "incoming",
1834
+ peerPrincipalId: senderPrincipalId,
1835
+ peerHelloLynkId: senderHelloLynkId,
1836
+ senderPrincipalId,
1837
+ senderDelegateId,
1838
+ authorizationScope: provenance.authorizationScope,
1839
+ intentSource: provenance.intentSource,
1840
+ senderPrincipalDisplayName,
1841
+ senderDelegateDisplayName,
1842
+ messageType: "text",
1843
+ text,
1844
+ transportSecurity: envelopeDigest ? E2EE_TRANSPORT_SECURITY : "plaintext-v0.1.0",
1845
+ envelopeDigest,
1846
+ senderIdentityKeyId,
1847
+ recipientIdentityKeyId,
1848
+ conversationBinding,
1849
+ status: "received",
1850
+ attempts: 0,
1851
+ nextRetryAt: null,
1852
+ lastErrorCode: null,
1853
+ createdAt: at,
1854
+ updatedAt: at,
1855
+ };
1856
+ store.messages.push(message);
1857
+ return { message: structuredClone(message), duplicate: false };
1858
+ }, { scanArchive: true });
1859
+ },
1860
+ async findIncomingE2eeReplay({ senderIdentityKeyId, clientMessageId, envelopeDigest }) {
1861
+ const acceptedSenderKeyId = safeId(senderIdentityKeyId, "e2ee_sender_identity_key_id_invalid");
1862
+ const acceptedClientMessageId = safeId(clientMessageId, "client_message_id_invalid");
1863
+ const acceptedDigest = safeSha256(envelopeDigest);
1864
+ return withPathMutationQueue(storePath, async () => {
1865
+ const store = await readCurrentStore();
1866
+ const archived = await scanArchive(archivePath, profileId, null, protection);
1867
+ const index = await readHistory();
1868
+ const existing = [...index.deleted, ...store.messages, ...archived].find((item) => (
1869
+ item.direction === "incoming" &&
1870
+ item.senderIdentityKeyId === acceptedSenderKeyId &&
1871
+ item.clientMessageId === acceptedClientMessageId
1872
+ ));
1873
+ if (!existing) return null;
1874
+ if (existing.envelopeDigest !== acceptedDigest) {
1875
+ throw new HelloLynkLocalMessagingError("e2ee_message_conflict", { httpStatus: 409 });
1876
+ }
1877
+ return structuredClone(existing);
1878
+ });
1879
+ },
1880
+ async getOutgoing(clientMessageId) {
1881
+ const acceptedId = safeId(clientMessageId, "client_message_id_invalid");
1882
+ return withPathMutationQueue(storePath, async () => {
1883
+ const store = await readCurrentStore();
1884
+ const index = await readHistory();
1885
+ const message = [...index.deleted, ...store.messages].find((item) => (
1886
+ item.direction === "outgoing" && item.clientMessageId === acceptedId
1887
+ ));
1888
+ if (message) return structuredClone(message);
1889
+ return scanArchive(archivePath, profileId, (item) => (
1890
+ item.direction === "outgoing" && item.clientMessageId === acceptedId
1891
+ ), protection);
1892
+ });
1893
+ },
1894
+ async updateIncoming(localMessageId, patch) {
1895
+ const acceptedId = safeId(localMessageId, "local_message_id_invalid");
1896
+ return mutate((store) => {
1897
+ const message = store.messages.find((item) => (
1898
+ item.direction === "incoming" && item.localMessageId === acceptedId
1899
+ ));
1900
+ if (!message) throw new HelloLynkLocalMessagingError("local_incoming_message_not_found");
1901
+ if (message.messageType === LOCAL_DELETED_MESSAGE_TYPE) throw localHistoryFailure("record_not_found");
1902
+ const nextStatus = String(patch.status || message.status);
1903
+ message.status = nextStatus;
1904
+ if (nextStatus === "failed_retryable") {
1905
+ message.attempts = Number(message.attempts || 0) + 1;
1906
+ const retryDelayMs = Math.min(1_000 * (2 ** Math.min(message.attempts - 1, 6)), 60_000);
1907
+ message.nextRetryAt = new Date(now() + retryDelayMs).toISOString();
1908
+ message.lastErrorCode = patch.lastErrorCode == null
1909
+ ? "inbound_projection_failed"
1910
+ : String(patch.lastErrorCode);
1911
+ } else if (["fetched", "pending_human_confirmation"].includes(nextStatus)) {
1912
+ message.nextRetryAt = null;
1913
+ message.lastErrorCode = null;
1914
+ }
1915
+ message.updatedAt = new Date(now()).toISOString();
1916
+ return structuredClone(message);
1917
+ });
1918
+ },
1919
+ async deleteIncoming(localMessageIds) {
1920
+ const acceptedIds = [...new Set(
1921
+ (Array.isArray(localMessageIds) ? localMessageIds : [])
1922
+ .map((item) => safeId(item, "local_message_id_invalid")),
1923
+ )];
1924
+ if (acceptedIds.length === 0) {
1925
+ throw new HelloLynkLocalMessagingError("local_message_ids_required");
1926
+ }
1927
+ if (acceptedIds.length > 500) {
1928
+ throw new HelloLynkLocalMessagingError("local_message_delete_capacity_exceeded");
1929
+ }
1930
+ return mutate(async (store, archived) => {
1931
+ const requested = new Set(acceptedIds);
1932
+ if ([...store.messages, ...archived].some((item) => requested.has(item.localMessageId) && localHistoryUnread(item))) {
1933
+ throw new HelloLynkLocalMessagingError("local_incoming_message_not_deletable", { httpStatus: 409 });
1934
+ }
1935
+ const deletedIds = [...new Set(
1936
+ [...store.messages, ...archived]
1937
+ .filter((item) => item.direction === "incoming" && item.messageType !== LOCAL_DELETED_MESSAGE_TYPE && requested.has(item.localMessageId))
1938
+ .map((item) => item.localMessageId),
1939
+ )];
1940
+ const deleted = new Set(deletedIds);
1941
+ const index = await readHistory();
1942
+ const tombstones = new Map(index.deleted.map((item) => [item.localMessageId, item]));
1943
+ for (const message of [...store.messages, ...archived]) {
1944
+ if (!deleted.has(message.localMessageId) || !message.conversationBinding) continue;
1945
+ const binding = message.conversationBinding;
1946
+ tombstones.set(message.localMessageId, localHistoryTombstone(message, {
1947
+ region: binding.region, principalId: binding.localPrincipalId, delegateId: binding.localDelegateId,
1948
+ }));
1949
+ }
1950
+ if (tombstones.size !== index.deleted.length) {
1951
+ index.deleted = [...tombstones.values()];
1952
+ index.cleanupPending = true;
1953
+ // Preserve the receipt identity before removing the body, so replay
1954
+ // after deletion cannot become a fresh automatic wake.
1955
+ await writeHistory(index);
1956
+ }
1957
+ store.messages = store.messages.filter((item) => (
1958
+ item.direction !== "incoming" || !deleted.has(item.localMessageId)
1959
+ ));
1960
+ if (archived.some((item) => (
1961
+ item.direction === "incoming" && deleted.has(item.localMessageId)
1962
+ ))) {
1963
+ await replaceArchiveMessages(
1964
+ archivePath,
1965
+ profileId,
1966
+ archived.filter((item) => (
1967
+ item.direction !== "incoming" || !deleted.has(item.localMessageId)
1968
+ )),
1969
+ protection,
1970
+ );
1971
+ }
1972
+ return {
1973
+ requested: acceptedIds.length,
1974
+ deleted: deletedIds.length,
1975
+ deletedIds: structuredClone(deletedIds),
1976
+ };
1977
+ }, { scanArchive: true });
1978
+ },
1979
+ async deleteOutgoing(clientMessageIds) {
1980
+ const acceptedIds = [...new Set(
1981
+ (Array.isArray(clientMessageIds) ? clientMessageIds : [])
1982
+ .map((item) => safeId(item, "client_message_id_invalid")),
1983
+ )];
1984
+ if (acceptedIds.length === 0) {
1985
+ throw new HelloLynkLocalMessagingError("client_message_ids_required");
1986
+ }
1987
+ if (acceptedIds.length > 500) {
1988
+ throw new HelloLynkLocalMessagingError("local_message_delete_capacity_exceeded");
1989
+ }
1990
+ return mutate(async (store, archived) => {
1991
+ const requested = new Set(acceptedIds);
1992
+ const selected = [...store.messages, ...archived].filter((item) => (
1993
+ item.direction === "outgoing" && requested.has(item.clientMessageId)
1994
+ ));
1995
+ if (selected.some((item) => item.messageType === LOCAL_DELETED_MESSAGE_TYPE || !DELETABLE_OUTGOING_STATUSES.has(item.status))) {
1996
+ throw new HelloLynkLocalMessagingError("local_outgoing_message_not_deletable", { httpStatus: 409 });
1997
+ }
1998
+ const deletedIds = [...new Set(selected.map((item) => item.clientMessageId))];
1999
+ const deleted = new Set(deletedIds);
2000
+ store.messages = store.messages.filter((item) => (
2001
+ item.direction !== "outgoing" || !deleted.has(item.clientMessageId)
2002
+ ));
2003
+ if (archived.some((item) => (
2004
+ item.direction === "outgoing" && deleted.has(item.clientMessageId)
2005
+ ))) {
2006
+ await replaceArchiveMessages(
2007
+ archivePath,
2008
+ profileId,
2009
+ archived.filter((item) => (
2010
+ item.direction !== "outgoing" || !deleted.has(item.clientMessageId)
2011
+ )),
2012
+ protection,
2013
+ );
2014
+ }
2015
+ return {
2016
+ requested: acceptedIds.length,
2017
+ deleted: deletedIds.length,
2018
+ deletedIds: structuredClone(deletedIds),
2019
+ };
2020
+ }, { scanArchive: true });
2021
+ },
2022
+ async cancelOutgoing(clientMessageId) {
2023
+ const acceptedId = safeId(clientMessageId, "client_message_id_invalid");
2024
+ return mutate((store) => {
2025
+ const message = store.messages.find((item) => (
2026
+ item.direction === "outgoing" && item.clientMessageId === acceptedId
2027
+ ));
2028
+ if (!message) throw new HelloLynkLocalMessagingError("local_outgoing_message_not_found");
2029
+ if (!["pending", "recipient_offline", "failed_retryable"].includes(message.status)) {
2030
+ throw new HelloLynkLocalMessagingError("local_outgoing_message_not_cancellable", { httpStatus: 409 });
2031
+ }
2032
+ message.status = "cancelled";
2033
+ message.lastErrorCode = null;
2034
+ message.nextRetryAt = null;
2035
+ message.deliveryAttemptPid = null;
2036
+ message.deliveryAttemptInstance = null;
2037
+ message.deliveryAttemptStartedAt = null;
2038
+ message.updatedAt = new Date(now()).toISOString();
2039
+ return structuredClone(message);
2040
+ });
2041
+ },
2042
+ async list() {
2043
+ return withPathMutationQueue(storePath, async () => {
2044
+ const store = await readCurrentStore();
2045
+ const archived = await scanArchive(archivePath, profileId, null, protection);
2046
+ return structuredClone(mergeMessageCopies(store, archived).filter((item) => item.messageType !== LOCAL_DELETED_MESSAGE_TYPE));
2047
+ });
2048
+ },
2049
+ async listActive() {
2050
+ return withPathMutationQueue(storePath, async () => {
2051
+ const store = await readCurrentStore();
2052
+ return structuredClone(store.messages.filter((item) => item.messageType !== LOCAL_DELETED_MESSAGE_TYPE));
2053
+ });
2054
+ },
2055
+ async getIncoming(localMessageId) {
2056
+ const acceptedId = safeId(localMessageId, "local_message_id_invalid");
2057
+ return withPathMutationQueue(storePath, async () => {
2058
+ const store = await readCurrentStore();
2059
+ const index = await readHistory();
2060
+ const message = [...index.deleted, ...store.messages].find((item) => (
2061
+ item.direction === "incoming" && item.localMessageId === acceptedId
2062
+ ));
2063
+ if (message) return structuredClone(message);
2064
+ return scanArchive(archivePath, profileId, (item) => (
2065
+ item.direction === "incoming" && item.localMessageId === acceptedId
2066
+ ), protection);
2067
+ });
2068
+ },
2069
+ async listPending() {
2070
+ return mutate((store) => {
2071
+ recoverAbandonedE2eeOutgoingAttempts(store, now);
2072
+ return structuredClone(store.messages.filter((item) => (
2073
+ item.direction === "outgoing" &&
2074
+ BACKGROUND_RETRYABLE_OUTGOING_STATUSES.has(item.status)
2075
+ )));
2076
+ });
2077
+ },
2078
+ };
2079
+
2080
+ async function inboxClaimMutation(input, authorize, finish) {
2081
+ const fields = ["adapterId", "conversationKey", "delegateId", "localMessageId",
2082
+ ...(finish ? ["claimId", "outcome"] : [])].sort();
2083
+ if (!hasExactFields(input, fields) || typeof authorize !== "function") {
2084
+ throw new HelloLynkLocalMessagingError("local_inbox_claim_invalid");
2085
+ }
2086
+ for (const key of ["adapterId", "conversationKey", "delegateId", "localMessageId"]) inboxReaderId(input[key]);
2087
+ if (finish && (!/^inclaim_[a-f0-9]{48}$/.test(input.claimId) || !["completed", "unknown"].includes(input.outcome))) {
2088
+ throw new HelloLynkLocalMessagingError("local_inbox_claim_invalid");
2089
+ }
2090
+ return withPathMutationQueue(storePath, async () => {
2091
+ const store = await readAdmissionStore();
2092
+ const boundary = store.inboxReaders?.find((item) => item.adapterId === input.adapterId && item.delegateId === input.delegateId)?.afterSeq;
2093
+ const message = store.messages.find((item) => item.localMessageId === input.localMessageId)
2094
+ || await scanArchive(archivePath, profileId, (item) => item.localMessageId === input.localMessageId, protection);
2095
+ const binding = message && getMessageConversationBinding(message);
2096
+ if (!message || message.direction !== "incoming" || message.messageType === LOCAL_DELETED_MESSAGE_TYPE
2097
+ || !binding || binding.localDelegateId !== input.delegateId || instanceConversationKey(binding) !== input.conversationKey
2098
+ || !Number.isSafeInteger(boundary) || !Number.isSafeInteger(message.receiptSeq) || message.receiptSeq <= boundary) {
2099
+ throw new HelloLynkLocalMessagingError("local_inbox_claim_ineligible");
2100
+ }
2101
+ // This callback must not acquire the message store lock recursively. It
2102
+ // checks current capability and read permission against this snapshot.
2103
+ await authorize(structuredClone(message), boundary, store.receiptHighWater);
2104
+ const existing = store.inboxClaims?.find((item) => item.delegateId === input.delegateId && item.localMessageId === input.localMessageId);
2105
+ if (finish) {
2106
+ if (!existing || existing.adapterId !== input.adapterId || existing.claimId !== input.claimId
2107
+ || existing.conversationKey !== input.conversationKey || existing.receiptSeq !== message.receiptSeq) {
2108
+ throw new HelloLynkLocalMessagingError("local_inbox_claim_mismatch");
2109
+ }
2110
+ if (existing.outcome !== "inflight" && existing.outcome !== input.outcome) {
2111
+ throw new HelloLynkLocalMessagingError("local_inbox_claim_terminal");
2112
+ }
2113
+ existing.outcome = input.outcome;
2114
+ await atomicWriteStore(storePath, store, maximumBytes, protection);
2115
+ await authorize(structuredClone(message), boundary, store.receiptHighWater);
2116
+ return { claimId: existing.claimId, outcome: existing.outcome };
2117
+ }
2118
+ // Every persisted state is terminal for automatic admission, including a
2119
+ // crash leaving inflight. Never expire, reset, prune or replay a claim.
2120
+ if (existing) return { claimed: false, claimId: null, message: null };
2121
+ const claim = { ...input, claimId: `inclaim_${crypto.randomBytes(24).toString("hex")}`,
2122
+ receiptSeq: message.receiptSeq, outcome: "inflight" };
2123
+ store.inboxClaims = [...(store.inboxClaims || []), claim];
2124
+ await atomicWriteStore(storePath, store, maximumBytes, protection);
2125
+ // If permission changed while committing, withhold the body and retain
2126
+ // inflight. A lost response is uncertain, never permission to retry.
2127
+ const projected = await authorize(structuredClone(message), boundary, store.receiptHighWater);
2128
+ return { claimed: true, claimId: claim.claimId, message: projected };
2129
+ });
2130
+ }
2131
+ }
2132
+
2133
+ // The caller holds the local mutation lock for the full iteration. Each
2134
+ // archive record is bounded before decoding, so migration never materializes
2135
+ // the complete (potentially unbounded) archive in memory.
2136
+ export async function* streamLegacyLocalMessages(options = {}) {
2137
+ const profileId = safeId(options.profileId, "local_message_profile_id_invalid");
2138
+ const storePath = String(options.storePath || "").trim();
2139
+ if (!storePath) throw new HelloLynkLocalMessagingError("local_message_store_path_required");
2140
+ const archivePath = `${storePath}.archive.ndjson`;
2141
+ const protection = normalizeMessageDataProtection(options.dataProtector, profileId);
2142
+ const legacy = createLocalMessageStore({ storePath, profileId, dataProtector: options.dataProtector });
2143
+ const store = await readStore(storePath, profileId, MAX_STORE_BYTES, protection);
2144
+ const history = await legacy.readLegacyHistoryForMigration();
2145
+ yield {
2146
+ kind: "metadata",
2147
+ store: {
2148
+ ...(Object.hasOwn(store, "receiptHighWater") ? { receiptHighWater: store.receiptHighWater } : {}),
2149
+ ...(Object.hasOwn(store, "inboxReaders") ? { inboxReaders: store.inboxReaders } : {}),
2150
+ ...(Object.hasOwn(store, "inboxClaims") ? { inboxClaims: store.inboxClaims } : {}),
2151
+ },
2152
+ history: { cleanupPending: history.cleanupPending, hidden: history.hidden },
2153
+ };
2154
+
2155
+ await assertMessageStoreAccess(archivePath, protection, { create: false });
2156
+ let metadata;
2157
+ try { metadata = await lstat(archivePath); }
2158
+ catch (error) {
2159
+ if (error?.code !== "ENOENT") throw error;
2160
+ }
2161
+ if (metadata) {
2162
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
2163
+ throw new HelloLynkLocalMessagingError("local_message_archive_invalid");
2164
+ }
2165
+ await recoverInterruptedArchiveTail(archivePath, profileId, protection);
2166
+ await assertMessageStoreAccess(archivePath, protection, { create: false });
2167
+ metadata = await lstat(archivePath);
2168
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
2169
+ throw new HelloLynkLocalMessagingError("local_message_archive_invalid");
2170
+ }
2171
+ const handle = await open(archivePath, "r");
2172
+ const maximumRecordBytes = protection ? MAX_PROTECTED_ARCHIVE_RECORD_BYTES : MAX_ARCHIVE_RECORD_BYTES;
2173
+ try {
2174
+ const opened = await handle.stat();
2175
+ if (!opened.isFile() || (process.platform !== "win32" && opened.dev !== metadata.dev) || opened.ino !== metadata.ino) {
2176
+ throw new HelloLynkLocalMessagingError("local_message_archive_invalid");
2177
+ }
2178
+ let pending = Buffer.alloc(0);
2179
+ for await (const chunk of handle.createReadStream({ highWaterMark: 64 * 1024, autoClose: false })) {
2180
+ let start = 0;
2181
+ for (let newline = chunk.indexOf(0x0a, start); newline >= 0; newline = chunk.indexOf(0x0a, start)) {
2182
+ const fragment = chunk.subarray(start, newline);
2183
+ if (pending.length + fragment.length > maximumRecordBytes) {
2184
+ throw new HelloLynkLocalMessagingError("local_message_archive_invalid");
2185
+ }
2186
+ const line = pending.length ? Buffer.concat([pending, fragment]) : fragment;
2187
+ if (line.length) yield { kind: "message", source: "archive",
2188
+ message: await parseArchiveEnvelope(line.toString("utf8"), profileId, protection) };
2189
+ pending = Buffer.alloc(0);
2190
+ start = newline + 1;
2191
+ }
2192
+ if (start < chunk.length) {
2193
+ const fragment = chunk.subarray(start);
2194
+ if (pending.length + fragment.length > maximumRecordBytes) {
2195
+ throw new HelloLynkLocalMessagingError("local_message_archive_invalid");
2196
+ }
2197
+ pending = pending.length ? Buffer.concat([pending, fragment]) : Buffer.from(fragment);
2198
+ }
2199
+ }
2200
+ if (pending.length) {
2201
+ throw new HelloLynkLocalMessagingError("local_message_archive_invalid");
2202
+ }
2203
+ } finally {
2204
+ await handle.close();
2205
+ }
2206
+ }
2207
+
2208
+ for (const message of store.messages) yield { kind: "message", source: "main", message };
2209
+ for (const message of history.deleted) yield { kind: "message", source: "deleted", message };
2210
+ }
2211
+
2212
+ export const indexedMessageStoreLegacyInternals = Object.freeze({
2213
+ safeId,
2214
+ safeText,
2215
+ inboxReaderId,
2216
+ normalizeStoredE2ee,
2217
+ provenanceFromStoredE2ee,
2218
+ normalizeMessageProvenance,
2219
+ optionalVerifiedDisplayName,
2220
+ validateCurrentMessage,
2221
+ localHistoryScope,
2222
+ localHistoryScopeKey,
2223
+ localHistoryBinding,
2224
+ localHistoryThreadId,
2225
+ localHistoryUnread,
2226
+ localHistorySent,
2227
+ localHistoryTombstone,
2228
+ localHistoryFailure,
2229
+ normalizeMessageDataProtection,
2230
+ readStore,
2231
+ assertMessageStoreAccess,
2232
+ syncParentDirectory,
2233
+ recoverInterruptedArchiveTail,
2234
+ parseArchiveEnvelope,
2235
+ withPathMutationQueue,
2236
+ recoverAbandonedE2eeOutgoingAttempts,
2237
+ deliveryAttemptHasLiveOwner,
2238
+ normalizeOutgoingRoute,
2239
+ safeSha256,
2240
+ PROCESS_INSTANCE_ID,
2241
+ MAX_STORE_BYTES,
2242
+ MAX_LOCAL_HISTORY_BYTES,
2243
+ MAX_ARCHIVE_RECORD_BYTES,
2244
+ MAX_PROTECTED_ARCHIVE_RECORD_BYTES,
2245
+ LOCAL_HISTORY_CONTRACT,
2246
+ LOCAL_DELETED_MESSAGE_TYPE,
2247
+ E2EE_DIRECT_TEXT_VERSION,
2248
+ E2EE_TRANSPORT_SECURITY,
2249
+ ARCHIVABLE_OUTGOING_STATUSES,
2250
+ ARCHIVABLE_INCOMING_STATUSES,
2251
+ DELETABLE_OUTGOING_STATUSES,
2252
+ MANUAL_RETRYABLE_OUTGOING_STATUSES,
2253
+ BACKGROUND_RETRYABLE_OUTGOING_STATUSES,
2254
+ CLAIMABLE_OUTGOING_STATUSES,
2255
+ });
2256
+
2257
+ function runtimeHeaders(session) {
2258
+ const sessionId = safeId(session?.sessionId || session?.id, "runtime_session_id_invalid");
2259
+ const sessionSecret = String(session?.sessionSecret || session?.secret || "");
2260
+ if (!/^runtime_[a-f0-9]{48}$/.test(sessionSecret)) {
2261
+ throw new HelloLynkLocalMessagingError("runtime_session_secret_invalid");
2262
+ }
2263
+ return {
2264
+ "x-hellolynk-runtime-session-id": sessionId,
2265
+ "x-hellolynk-runtime-session-secret": sessionSecret,
2266
+ };
2267
+ }
2268
+
2269
+ function parseSseBlock(block) {
2270
+ let event = "message";
2271
+ const data = [];
2272
+ for (const line of block.split(/\r?\n/)) {
2273
+ if (line.startsWith(":")) continue;
2274
+ if (line.startsWith("event:")) event = line.slice(6).trim();
2275
+ if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
2276
+ }
2277
+ if (data.length === 0) return null;
2278
+ try {
2279
+ return { event, data: JSON.parse(data.join("\n")) };
2280
+ } catch {
2281
+ throw new HelloLynkLocalMessagingError("relay_event_invalid_json");
2282
+ }
2283
+ }
2284
+
2285
+ export function createOnlineRelayTransport(options = {}) {
2286
+ const baseUrl = normalizeHelloLynkBaseUrl(options.baseUrl, {
2287
+ allowLoopbackHttp: options.allowLoopbackHttp === true,
2288
+ });
2289
+ const fetchImpl = options.fetchImpl || runtimeSafety.helloLynkNetworkFetch;
2290
+ const sessionProvider = options.sessionProvider;
2291
+ const abortSignal = options.abortSignal;
2292
+ if (typeof fetchImpl !== "function") throw new HelloLynkLocalMessagingError("fetch_unavailable");
2293
+ if (!sessionProvider || typeof sessionProvider.get !== "function") {
2294
+ throw new HelloLynkLocalMessagingError("runtime_session_provider_required");
2295
+ }
2296
+
2297
+ async function sendText(input) {
2298
+ const session = await sessionProvider.get();
2299
+ const e2ee = normalizeStoredE2ee(input.e2ee);
2300
+ const body = e2ee ? {
2301
+ ...(e2ee.version === "2" ? {
2302
+ routeKind: e2ee.routeKind,
2303
+ recipientDelegateId: e2ee.recipientDelegateId,
2304
+ recipientHostId: e2ee.recipientHostId,
2305
+ recipientDeviceId: e2ee.recipientDeviceId,
2306
+ ...(e2ee.nativeContentVersion === 1 ? { nativeContentVersion: 1 } : {}),
2307
+ } : {}),
2308
+ recipientHelloLynkId: safeId(input.recipientHelloLynkId, "recipient_hellolynk_id_invalid"),
2309
+ clientMessageId: safeId(input.clientMessageId, "client_message_id_invalid"),
2310
+ messageType: "text",
2311
+ claimId: e2ee.claimId,
2312
+ envelopeDigest: e2ee.envelopeDigest,
2313
+ e2eeEnvelope: e2ee.envelope,
2314
+ } : {
2315
+ recipientHelloLynkId: safeId(input.recipientHelloLynkId, "recipient_hellolynk_id_invalid"),
2316
+ clientMessageId: safeId(input.clientMessageId, "client_message_id_invalid"),
2317
+ messageType: "text",
2318
+ text: safeText(input.text),
2319
+ };
2320
+ const headers = runtimeHeaders(session);
2321
+ let response;
2322
+ try {
2323
+ response = await fetchImpl(`${baseUrl}/api/${e2ee?.version === "2" ? "v2" : "v1"}/relay/messages`, {
2324
+ method: "POST",
2325
+ headers: {
2326
+ accept: "application/json",
2327
+ "content-type": "application/json",
2328
+ "x-hellolynk-relay-contract": RECEIVER_LOCAL_STORE_CONTRACT,
2329
+ ...(e2ee ? { "x-hellolynk-e2ee-version": e2ee.version } : {}),
2330
+ ...headers,
2331
+ },
2332
+ body: JSON.stringify(body),
2333
+ signal: input.signal || abortSignal,
2334
+ redirect: "error",
2335
+ });
2336
+ } catch (error) {
2337
+ throw new HelloLynkLocalMessagingError("relay_transport_error", { cause: error, retryable: true });
2338
+ }
2339
+ // The edge rejects oversized requests before forwarding, and may return
2340
+ // HTML rather than JSON. This status is a definite rejection, not unknown.
2341
+ if (response.status === 413) throw new HelloLynkLocalMessagingError("relay_request_body_too_large", { httpStatus: 413 });
2342
+ const raw = await readBoundedText(response);
2343
+ let data;
2344
+ try {
2345
+ // An HTTP status with a missing body cannot prove whether relay dispatch
2346
+ // happened. Keep it unconfirmed just like other invalid JSON responses.
2347
+ data = JSON.parse(raw);
2348
+ } catch {
2349
+ throw new HelloLynkLocalMessagingError("relay_response_invalid_json");
2350
+ }
2351
+ if (response.status === 200 && data?.status === "acknowledged_by_recipient_local_store") return data;
2352
+ const explicitRelayStatus = String(data?.status || data?.error || "");
2353
+ if (e2ee?.nativeContentVersion === 1 && (
2354
+ (response.status === 409 && explicitRelayStatus === "native_directory_unavailable")
2355
+ || (response.status === 400 && explicitRelayStatus === "e2ee_v2_relay_message_fields_invalid")
2356
+ )) throw new HelloLynkLocalMessagingError("native_directory_unavailable", { httpStatus: response.status });
2357
+ if (response.status >= 400 && [
2358
+ "recipient_offline",
2359
+ "relay_unavailable",
2360
+ "recipient_ack_capability_unavailable",
2361
+ "recipient_e2ee_device_offline",
2362
+ "recipient_e2ee_ack_capability_unavailable",
2363
+ "receiver_ack_timeout",
2364
+ "recipient_disconnected_before_ack",
2365
+ "relay_service_stopped_before_ack",
2366
+ "relay_ack_capacity_exhausted",
2367
+ "delivery_unconfirmed",
2368
+ "reply_target_deleted",
2369
+ ].includes(explicitRelayStatus)) return { ...data, status: explicitRelayStatus };
2370
+ const status = Number(response.status);
2371
+ throw new HelloLynkLocalMessagingError(
2372
+ publicRemoteCode(data?.error, `relay_http_${status}`),
2373
+ { httpStatus: status, retryable: RETRYABLE_STATUSES.has(status) || status >= 500 },
2374
+ );
2375
+ }
2376
+
2377
+ async function openEventStream() {
2378
+ const session = await sessionProvider.get();
2379
+ const headers = runtimeHeaders(session);
2380
+ const controller = new AbortController();
2381
+ const abortFromLifecycle = () => controller.abort(abortSignal?.reason);
2382
+ if (abortSignal?.aborted) abortFromLifecycle();
2383
+ else abortSignal?.addEventListener?.("abort", abortFromLifecycle, { once: true });
2384
+ const releaseLifecycleAbort = () => abortSignal?.removeEventListener?.("abort", abortFromLifecycle);
2385
+ let response;
2386
+ try {
2387
+ const receiveVersion = String(options.e2eeReceiver?.version || E2EE_DIRECT_TEXT_VERSION);
2388
+ response = await fetchImpl(`${baseUrl}/api/${receiveVersion === "2" ? "v2" : "v1"}/relay/events`, {
2389
+ headers: {
2390
+ accept: "text/event-stream",
2391
+ "x-hellolynk-relay-receipt-version": RECEIVER_RECEIPT_VERSION,
2392
+ ...(options.e2eeReceiver?.identityKeyId ? {
2393
+ "x-hellolynk-e2ee-receive-version": receiveVersion,
2394
+ "x-hellolynk-e2ee-identity-key-id": safeId(
2395
+ options.e2eeReceiver.identityKeyId,
2396
+ "e2ee_recipient_identity_key_id_invalid",
2397
+ ),
2398
+ } : {}),
2399
+ ...headers,
2400
+ },
2401
+ signal: controller.signal,
2402
+ redirect: "error",
2403
+ });
2404
+ } catch (error) {
2405
+ releaseLifecycleAbort();
2406
+ throw new HelloLynkLocalMessagingError("relay_transport_error", { cause: error, retryable: true });
2407
+ }
2408
+ if (response.status !== 200 || !String(response.headers.get("content-type") || "").startsWith("text/event-stream")) {
2409
+ const raw = await readBoundedText(response).catch(() => "");
2410
+ let data = null;
2411
+ try { data = raw ? JSON.parse(raw) : null; } catch { /* 使用统一安全错误码。 */ }
2412
+ releaseLifecycleAbort();
2413
+ controller.abort();
2414
+ throw new HelloLynkLocalMessagingError(
2415
+ publicRemoteCode(data?.error, `relay_http_${response.status}`),
2416
+ { httpStatus: response.status, retryable: RETRYABLE_STATUSES.has(response.status) || response.status >= 500 },
2417
+ );
2418
+ }
2419
+ const reader = response.body.getReader();
2420
+ const decoder = new TextDecoder();
2421
+ let buffer = "";
2422
+
2423
+ async function nextEvent() {
2424
+ while (true) {
2425
+ const boundary = buffer.indexOf("\n\n");
2426
+ if (boundary >= 0) {
2427
+ const block = buffer.slice(0, boundary);
2428
+ buffer = buffer.slice(boundary + 2);
2429
+ const event = parseSseBlock(block);
2430
+ if (event) return event;
2431
+ continue;
2432
+ }
2433
+ const result = await reader.read();
2434
+ if (result.done) throw new HelloLynkLocalMessagingError("relay_event_stream_closed", { retryable: true });
2435
+ buffer += decoder.decode(result.value, { stream: true }).replace(/\r\n/g, "\n");
2436
+ if (Buffer.byteLength(buffer, "utf8") > MAX_SSE_BUFFER_BYTES) {
2437
+ controller.abort();
2438
+ await reader.cancel().catch(() => {});
2439
+ throw new HelloLynkLocalMessagingError("relay_event_too_large");
2440
+ }
2441
+ }
2442
+ }
2443
+
2444
+ async function acknowledgePersisted(input) {
2445
+ const version = String(input?.e2eeVersion || E2EE_DIRECT_TEXT_VERSION);
2446
+ const body = {
2447
+ deliveryAttemptId: safeId(input?.deliveryAttemptId, "relay_delivery_attempt_id_invalid"),
2448
+ eventId: safeId(input?.eventId, "relay_event_id_invalid"),
2449
+ clientMessageId: safeId(input?.clientMessageId, "client_message_id_invalid"),
2450
+ connectionId: safeId(input?.connectionId, "relay_connection_id_invalid"),
2451
+ state: version === "2" && input.replyTargetDeleted === true ? "reply_target_deleted" : "persisted_local",
2452
+ ...(input?.envelopeDigest ? {
2453
+ envelopeDigest: safeSha256(input.envelopeDigest),
2454
+ } : {}),
2455
+ ...(version === "2" ? {
2456
+ e2eeVersion: 2,
2457
+ routeKind: input.routeKind,
2458
+ recipientDelegateId: input.recipientDelegateId,
2459
+ recipientDelegateAuthorizationVersion: input.recipientDelegateAuthorizationVersion,
2460
+ recipientHostId: input.recipientHostId,
2461
+ recipientRouteVersion: input.recipientRouteVersion,
2462
+ recipientDeviceId: input.recipientDeviceId,
2463
+ recipientIdentityKeyId: input.recipientIdentityKeyId,
2464
+ recipientPreKeyId: input.recipientPreKeyId,
2465
+ } : {}),
2466
+ };
2467
+ let ackResponse;
2468
+ try {
2469
+ ackResponse = await fetchImpl(`${baseUrl}/api/${version === "2" ? "v2" : "v1"}/relay/acks`, {
2470
+ method: "POST",
2471
+ headers: {
2472
+ accept: "application/json",
2473
+ "content-type": "application/json",
2474
+ ...headers,
2475
+ },
2476
+ body: JSON.stringify(body),
2477
+ redirect: "error",
2478
+ });
2479
+ } catch (error) {
2480
+ throw new HelloLynkLocalMessagingError("relay_ack_transport_error", { cause: error, retryable: true });
2481
+ }
2482
+ const raw = await readBoundedText(ackResponse);
2483
+ let data;
2484
+ try {
2485
+ data = raw ? JSON.parse(raw) : {};
2486
+ } catch {
2487
+ throw new HelloLynkLocalMessagingError("relay_ack_response_invalid_json", { retryable: true });
2488
+ }
2489
+ if (
2490
+ ackResponse.status === 200 &&
2491
+ (data?.status === (body.state === "reply_target_deleted" ? "reply_target_deleted" : "acknowledged_by_recipient_local_store")
2492
+ || (body.state === "persisted_local" && data?.status === "recipient_local_store_persisted"))
2493
+ ) return data;
2494
+ const status = Number(ackResponse.status);
2495
+ throw new HelloLynkLocalMessagingError(
2496
+ publicRemoteCode(data?.error, `relay_ack_http_${status}`),
2497
+ { httpStatus: status, retryable: status === 404 || RETRYABLE_STATUSES.has(status) || status >= 500 },
2498
+ );
2499
+ }
2500
+
2501
+ return {
2502
+ nextEvent,
2503
+ acknowledgePersisted,
2504
+ async close() {
2505
+ releaseLifecycleAbort();
2506
+ controller.abort();
2507
+ await reader.cancel().catch(() => {});
2508
+ },
2509
+ };
2510
+ }
2511
+
2512
+ return { sendText, openEventStream };
2513
+ }
2514
+
2515
+ export function createLocalMessenger(options = {}) {
2516
+ const principalId = safeId(options.principalId, "local_principal_id_invalid");
2517
+ const delegateId = safeId(options.delegateId, "local_delegate_id_invalid");
2518
+ const e2ee = options.e2ee || null;
2519
+ const e2eeMode = String(options.e2eeMode || e2ee?.mode || (e2ee ? "transition" : "disabled"));
2520
+ if (!new Set(["disabled", "transition", "required"]).has(e2eeMode)) {
2521
+ throw new HelloLynkLocalMessagingError("e2ee_mode_invalid");
2522
+ }
2523
+ if (e2eeMode === "required" && !e2ee) {
2524
+ throw new HelloLynkLocalMessagingError("e2ee_context_required");
2525
+ }
2526
+ const e2eeIdentityKeyId = e2ee
2527
+ ? safeId(e2ee.identityKeyId, "e2ee_identity_key_id_invalid")
2528
+ : null;
2529
+ const e2eeVersion = String(e2ee?.version || "1");
2530
+ const durableBeforeEncryption = options.durableBeforeEncryption === true;
2531
+ if (e2ee && (
2532
+ typeof e2ee.prepareOutgoing !== "function" ||
2533
+ (e2ee.listPreparedOutgoing != null && typeof e2ee.listPreparedOutgoing !== "function") ||
2534
+ (e2ee.commitOutgoing != null && typeof e2ee.commitOutgoing !== "function") ||
2535
+ (e2ee.ensurePreKeyInventory != null && typeof e2ee.ensurePreKeyInventory !== "function") ||
2536
+ (e2ee.replenishPreKeyInventory != null && typeof e2ee.replenishPreKeyInventory !== "function") ||
2537
+ typeof e2ee.openIncoming !== "function" ||
2538
+ (e2ee.commitIncomingReplay != null && typeof e2ee.commitIncomingReplay !== "function")
2539
+ )) throw new HelloLynkLocalMessagingError("e2ee_context_invalid");
2540
+ const store = options.store || createLocalMessageStore({
2541
+ profileId: options.profileId,
2542
+ storePath: options.storePath,
2543
+ now: options.now,
2544
+ });
2545
+ const transport = options.transport || createOnlineRelayTransport({
2546
+ ...options,
2547
+ e2eeReceiver: e2ee ? { identityKeyId: e2eeIdentityKeyId, version: e2eeVersion } : null,
2548
+ });
2549
+
2550
+ let cancelledPreparationRecoveryPending = true;
2551
+ async function recoverCancelledPreparations(clientMessageId = null) {
2552
+ if (!durableBeforeEncryption || typeof e2ee?.listPreparedOutgoing !== "function"
2553
+ || typeof e2ee?.commitOutgoing !== "function") return;
2554
+ if (clientMessageId == null) cancelledPreparationRecoveryPending = false;
2555
+ try {
2556
+ const prepared = await e2ee.listPreparedOutgoing();
2557
+ for (const entry of prepared) {
2558
+ if (clientMessageId != null && entry.clientMessageId !== clientMessageId) continue;
2559
+ const saved = await store.getOutgoing(entry.clientMessageId);
2560
+ // Cancellation is terminal, including in a body-deleted tombstone.
2561
+ // Missing, retryable and unknown records are not cancellation evidence.
2562
+ if (saved?.direction !== "outgoing" || saved.status !== "cancelled"
2563
+ || saved.senderPrincipalId !== principalId || saved.senderDelegateId !== delegateId
2564
+ || saved.peerHelloLynkId !== entry.recipientHelloLynkId) continue;
2565
+ await e2ee.commitOutgoing(entry);
2566
+ }
2567
+ } catch {
2568
+ // Cancellation has already committed. A cleanup failure must neither
2569
+ // undo it nor resend; retry cleanup before the next preparation/restart.
2570
+ cancelledPreparationRecoveryPending = true;
2571
+ }
2572
+ }
2573
+
2574
+ async function prepare(message, preparation, signal) {
2575
+ throwIfAborted(signal);
2576
+ if (cancelledPreparationRecoveryPending) await recoverCancelledPreparations();
2577
+ throwIfAborted(signal);
2578
+ const sealed = normalizeStoredE2ee(await e2ee.prepareOutgoing({
2579
+ recipientHelloLynkId: message.peerHelloLynkId,
2580
+ text: message.text,
2581
+ clientMessageId: message.clientMessageId,
2582
+ senderPrincipalId: principalId,
2583
+ senderDelegateId: delegateId,
2584
+ intentSource: preparation.intentSource,
2585
+ ...(message.replyToClientMessageId ? { replyToClientMessageId: message.replyToClientMessageId } : {}),
2586
+ ...(message.targetConversationId ? { targetConversationId: message.targetConversationId } : {}),
2587
+ ...(normalizeOutgoingRoute(preparation.route, e2ee.version) || {}),
2588
+ }, { signal, persistPrepared: !durableBeforeEncryption }));
2589
+ throwIfAborted(signal);
2590
+ if (!sealed) throw new HelloLynkLocalMessagingError("e2ee_required", { httpStatus: 426 });
2591
+ return sealed;
2592
+ }
2593
+
2594
+ async function attempt(clientMessageId, context = {}) {
2595
+ const pending = await store.getOutgoing(clientMessageId);
2596
+ if (!pending) throw new HelloLynkLocalMessagingError("local_outgoing_message_not_found");
2597
+ if (pending.targetConversationTitle) safeId(context.friendTargetConnectionId, "native_target_not_authorized");
2598
+ if ((e2eeMode === "required" || pending.e2eePreparation != null) && !pending.e2ee) {
2599
+ await store.updateOutgoing(clientMessageId, {
2600
+ status: pending.e2eePreparation != null ? "failed_retryable" : "failed_permanent",
2601
+ lastErrorCode: "e2ee_required",
2602
+ incrementAttempts: false,
2603
+ });
2604
+ throw new HelloLynkLocalMessagingError("e2ee_required", { httpStatus: 426 });
2605
+ }
2606
+ const message = await store.claimOutgoingForAttempt(clientMessageId, {
2607
+ senderPrincipalId: principalId,
2608
+ senderDelegateId: delegateId,
2609
+ });
2610
+ const relayMessage = {
2611
+ recipientHelloLynkId: message.recipientHelloLynkId || message.peerHelloLynkId,
2612
+ clientMessageId: message.clientMessageId,
2613
+ text: message.text,
2614
+ e2ee: message.e2ee || null,
2615
+ signal: context.signal,
2616
+ };
2617
+ try {
2618
+ throwIfAborted(context.signal);
2619
+ if (message.e2ee && typeof e2ee?.commitOutgoing === "function") {
2620
+ await e2ee.commitOutgoing({
2621
+ clientMessageId: message.clientMessageId,
2622
+ recipientHelloLynkId: message.recipientHelloLynkId || message.peerHelloLynkId,
2623
+ envelopeDigest: message.e2ee.envelopeDigest,
2624
+ });
2625
+ }
2626
+ throwIfAborted(context.signal);
2627
+ } catch (error) {
2628
+ // No transport call has started. Release only our exact durable claim;
2629
+ // if that state cannot be persisted, leave it frozen rather than resend.
2630
+ await store.updateOutgoing(message.clientMessageId, {
2631
+ status: "failed_retryable", lastErrorCode: safeLocalErrorCode(error), incrementAttempts: false,
2632
+ beforeTransportClaim: { attempts: message.attempts, startedAt: message.deliveryAttemptStartedAt },
2633
+ }).catch(() => {});
2634
+ throw error;
2635
+ }
2636
+ let result;
2637
+ try {
2638
+ result = await transport.sendText(relayMessage);
2639
+ } catch (error) {
2640
+ const accepted = error instanceof HelloLynkLocalMessagingError
2641
+ ? error
2642
+ : new HelloLynkLocalMessagingError("relay_transport_error", { cause: error, retryable: true });
2643
+ const acceptedHttpStatus = Number(accepted.httpStatus);
2644
+ const definitelyRejected = (
2645
+ Number.isInteger(acceptedHttpStatus) &&
2646
+ acceptedHttpStatus >= 400 &&
2647
+ acceptedHttpStatus < 500 &&
2648
+ acceptedHttpStatus !== 408
2649
+ );
2650
+ const failureStatus = [425, 429].includes(acceptedHttpStatus)
2651
+ ? "failed_retryable"
2652
+ : definitelyRejected
2653
+ ? "failed_permanent"
2654
+ : "delivery_state_unconfirmed";
2655
+ const updated = await store.updateOutgoing(message.clientMessageId, {
2656
+ status: failureStatus,
2657
+ lastErrorCode: accepted.code,
2658
+ incrementAttempts: false,
2659
+ settleDelivery: true,
2660
+ });
2661
+ if (failureStatus === "failed_permanent") throw accepted;
2662
+ return { ok: false, status: updated.status, message: updated, errorCode: accepted.code };
2663
+ }
2664
+
2665
+ // Capacity can fail before dispatch or after the recipient has persisted.
2666
+ // Retry only when the relay explicitly reports that no attempt was created.
2667
+ const rejectedBeforeRelayAttempt = ["relay_unavailable", "relay_ack_capacity_exhausted"].includes(result.status)
2668
+ && result.deliveredConnectionCount === 0
2669
+ && ["deliveryAttemptId", "eventId", "targetConnectionId", "relayedAt", "ackDeadlineAt"]
2670
+ .every((field) => result[field] === null);
2671
+ const relayAccepted = ["acknowledged_by_recipient_local_store", "delivered_to_live_connection"].includes(result.status);
2672
+ // The queried receiver may have disconnected or downgraded before the
2673
+ // write. A receipt from another connection cannot establish delivery.
2674
+ const friendReceiptUnconfirmed = message.targetConversationTitle && relayAccepted
2675
+ && (result.status !== "acknowledged_by_recipient_local_store" || result.targetConnectionId !== context.friendTargetConnectionId);
2676
+ const finalStatus = relayAccepted
2677
+ ? friendReceiptUnconfirmed ? "delivery_state_unconfirmed" : "delivered_to_live_connection"
2678
+ : result.status === "reply_target_deleted" && message.replyToClientMessageId
2679
+ ? "failed_permanent"
2680
+ : result.status === "recipient_offline"
2681
+ ? "recipient_offline"
2682
+ : rejectedBeforeRelayAttempt || [
2683
+ "recipient_ack_capability_unavailable",
2684
+ "recipient_e2ee_device_offline",
2685
+ "recipient_e2ee_ack_capability_unavailable",
2686
+ ].includes(result.status)
2687
+ ? "failed_retryable"
2688
+ : "delivery_state_unconfirmed";
2689
+ let updated;
2690
+ try {
2691
+ updated = await store.updateOutgoing(message.clientMessageId, {
2692
+ status: finalStatus,
2693
+ lastErrorCode: finalStatus === "delivered_to_live_connection" ? null
2694
+ : friendReceiptUnconfirmed ? "native_target_connection_changed" : result.status,
2695
+ incrementAttempts: false,
2696
+ settleDelivery: true,
2697
+ });
2698
+ } catch (error) {
2699
+ await store.updateOutgoing(message.clientMessageId, {
2700
+ status: "delivery_state_unconfirmed",
2701
+ lastErrorCode: "local_delivery_state_persist_failed",
2702
+ incrementAttempts: false,
2703
+ settleDelivery: true,
2704
+ }).catch(() => {});
2705
+ throw new HelloLynkLocalMessagingError("local_delivery_state_persist_failed", {
2706
+ cause: error,
2707
+ retryable: false,
2708
+ deliveryStatus: result.status,
2709
+ });
2710
+ }
2711
+ return {
2712
+ ok: finalStatus === "delivered_to_live_connection",
2713
+ status: updated.status,
2714
+ message: updated,
2715
+ relay: result,
2716
+ };
2717
+ }
2718
+
2719
+ return {
2720
+ principalId,
2721
+ delegateId,
2722
+ store,
2723
+ async sendText({ recipientHelloLynkId, recipientPrincipalId, recipientDelegateId, region, text, clientMessageId, intentSource, route, signal, replyToClientMessageId, targetConversationId, targetConversationTitle, friendTargetConnectionId, resendSourceClientMessageId }) {
2724
+ const acceptedClientMessageId = clientMessageId || `hellolynk_local_${crypto.randomUUID()}`;
2725
+ const acceptedText = safeText(text);
2726
+ const reply = replyToClientMessageId == null ? {} : { replyToClientMessageId: safeId(replyToClientMessageId, "reply_target_invalid") };
2727
+ if (targetConversationTitle != null || friendTargetConnectionId != null) {
2728
+ if (!targetConversationId || recipientPrincipalId === principalId || route?.routeKind !== "instance_direct")
2729
+ throw new HelloLynkLocalMessagingError("native_target_not_authorized");
2730
+ safeId(friendTargetConnectionId, "native_target_not_authorized");
2731
+ reply.targetConversationTitle = nativeConversationTitle(targetConversationTitle);
2732
+ }
2733
+ if (targetConversationId != null) {
2734
+ if (replyToClientMessageId != null || !e2ee || e2eeVersion !== "2" || (recipientPrincipalId !== principalId && !reply.targetConversationTitle)
2735
+ || intentSource !== "human_preauthorized_contact") throw new HelloLynkLocalMessagingError("native_target_not_authorized");
2736
+ reply.targetConversationId = nativeConversationId(targetConversationId);
2737
+ }
2738
+ if (replyToClientMessageId != null && (e2eeVersion !== "2" || !e2ee || intentSource !== "human_preauthorized_contact"
2739
+ || !["instance_direct", "owned_delegate"].includes(route?.routeKind))) throw new HelloLynkLocalMessagingError("reply_target_invalid");
2740
+ const resend = resendSourceClientMessageId == null ? {} : { resendSourceClientMessageId: safeId(resendSourceClientMessageId, "reply_resend_invalid") };
2741
+ if (resendSourceClientMessageId != null && (!durableBeforeEncryption || e2eeVersion !== "2" || !e2ee
2742
+ || intentSource !== "human_native_dialog" || replyToClientMessageId != null)) throw new HelloLynkLocalMessagingError("reply_resend_invalid");
2743
+ const conversationIntent = createOutgoingConversationIntent({
2744
+ region, recipientHelloLynkId, recipientPrincipalId,
2745
+ recipientDelegateId: recipientDelegateId ?? route?.recipientDelegateId,
2746
+ senderPrincipalId: principalId, senderDelegateId: delegateId,
2747
+ });
2748
+ let sealed = null;
2749
+ let message = null;
2750
+ const preparation = e2ee ? {
2751
+ intentSource: intentSource == null ? "hellolynk_client_explicit_send" : safeId(intentSource, "e2ee_intent_source_invalid"),
2752
+ route: normalizeOutgoingRoute(route, e2ee.version),
2753
+ } : null;
2754
+ if (preparation && !DIRECT_TEXT_E2EE_ALLOWED_INTENT_SOURCES.includes(preparation.intentSource)) {
2755
+ throw new HelloLynkLocalMessagingError("e2ee_intent_source_invalid");
2756
+ }
2757
+ if (durableBeforeEncryption) {
2758
+ message = await store.addOutgoing({
2759
+ recipientHelloLynkId,
2760
+ text: acceptedText,
2761
+ clientMessageId: acceptedClientMessageId,
2762
+ senderPrincipalId: principalId,
2763
+ senderDelegateId: delegateId,
2764
+ e2ee: null,
2765
+ e2eePreparation: preparation,
2766
+ ...reply,
2767
+ ...resend,
2768
+ conversationIntent,
2769
+ });
2770
+ }
2771
+ if (e2ee) {
2772
+ try {
2773
+ sealed = await prepare(message || {
2774
+ peerHelloLynkId: recipientHelloLynkId, text: acceptedText, clientMessageId: acceptedClientMessageId,
2775
+ ...reply,
2776
+ }, preparation, signal);
2777
+ if (durableBeforeEncryption) message = await store.attachOutgoingE2ee(message.clientMessageId, sealed);
2778
+ } catch (error) {
2779
+ if (message) {
2780
+ await store.updateOutgoing(message.clientMessageId, {
2781
+ status: "failed_retryable",
2782
+ lastErrorCode: safeLocalErrorCode(error),
2783
+ incrementAttempts: false,
2784
+ preparationOnly: true,
2785
+ }).catch(() => {});
2786
+ await recoverCancelledPreparations(message.clientMessageId);
2787
+ }
2788
+ throw error;
2789
+ }
2790
+ } else if (route != null) {
2791
+ throw new HelloLynkLocalMessagingError("e2ee_route_without_e2ee");
2792
+ }
2793
+ if (e2eeMode === "required" && !sealed) {
2794
+ throw new HelloLynkLocalMessagingError("e2ee_required", { httpStatus: 426 });
2795
+ }
2796
+ if (!message) {
2797
+ message = await store.addOutgoing({
2798
+ recipientHelloLynkId,
2799
+ text: acceptedText,
2800
+ clientMessageId: acceptedClientMessageId,
2801
+ senderPrincipalId: principalId,
2802
+ senderDelegateId: delegateId,
2803
+ e2ee: sealed,
2804
+ conversationIntent,
2805
+ ...reply,
2806
+ });
2807
+ }
2808
+ try {
2809
+ throwIfAborted(signal);
2810
+ } catch (error) {
2811
+ if (durableBeforeEncryption && message) {
2812
+ await store.updateOutgoing(message.clientMessageId, {
2813
+ status: "failed_retryable",
2814
+ lastErrorCode: safeLocalErrorCode(error),
2815
+ incrementAttempts: false,
2816
+ preparationOnly: true,
2817
+ }).catch(() => {});
2818
+ }
2819
+ throw error;
2820
+ }
2821
+ return attempt(message.clientMessageId, { signal, friendTargetConnectionId });
2822
+ },
2823
+ async retry(clientMessageId, { signal, friendTargetConnectionId } = {}) {
2824
+ await store.recoverArchivedOutgoingRetryClaim?.({ clientMessageId });
2825
+ await store.recoverAbandonedE2eeOutgoingAttempts?.({ clientMessageId });
2826
+ const message = await store.getOutgoing(clientMessageId);
2827
+ if (!message) throw new HelloLynkLocalMessagingError("local_outgoing_message_not_found");
2828
+ if (message.targetConversationTitle) safeId(friendTargetConnectionId, "native_target_not_authorized");
2829
+ if (!MANUAL_RETRYABLE_OUTGOING_STATUSES.has(message.status) || message.lastErrorCode === "reply_target_deleted") {
2830
+ throw new HelloLynkLocalMessagingError("local_outgoing_message_not_retryable", { httpStatus: 409 });
2831
+ }
2832
+ if (!message.e2ee && e2ee && message.attempts === 0) {
2833
+ const claimed = await store.claimOutgoingForPreparation(clientMessageId, {
2834
+ senderPrincipalId: principalId, senderDelegateId: delegateId,
2835
+ });
2836
+ try {
2837
+ const sealed = await prepare(claimed, claimed.e2eePreparation, signal);
2838
+ await store.attachOutgoingE2ee(clientMessageId, sealed);
2839
+ throwIfAborted(signal);
2840
+ } catch (error) {
2841
+ await store.updateOutgoing(clientMessageId, {
2842
+ status: "failed_retryable", lastErrorCode: safeLocalErrorCode(error),
2843
+ incrementAttempts: false, preparationOnly: true,
2844
+ }).catch(() => {});
2845
+ await recoverCancelledPreparations(clientMessageId);
2846
+ throw error;
2847
+ }
2848
+ }
2849
+ throwIfAborted(signal);
2850
+ return attempt(clientMessageId, { signal, friendTargetConnectionId });
2851
+ },
2852
+ async cancel(clientMessageId) {
2853
+ const message = await store.cancelOutgoing(clientMessageId);
2854
+ await recoverCancelledPreparations(clientMessageId);
2855
+ return message;
2856
+ },
2857
+ async openReceiver() {
2858
+ if (typeof e2ee?.ensurePreKeyInventory === "function") {
2859
+ await e2ee.ensurePreKeyInventory();
2860
+ }
2861
+ const stream = await transport.openEventStream();
2862
+ let ready;
2863
+ try {
2864
+ ready = await stream.nextEvent();
2865
+ } catch (error) {
2866
+ await stream.close().catch(() => {});
2867
+ throw error;
2868
+ }
2869
+ const readyMismatchCode = ready.event !== "ready"
2870
+ ? "relay_ready_event_type_mismatch"
2871
+ : ready.data?.principalId !== principalId
2872
+ ? "relay_ready_principal_mismatch"
2873
+ : ready.data?.delegateId !== delegateId
2874
+ ? "relay_ready_delegate_mismatch"
2875
+ : ready.data?.storage !== "none"
2876
+ ? "relay_ready_storage_mode_mismatch"
2877
+ : ready.data?.receiverPersistedAckVersion !== 1
2878
+ ? "relay_ready_receipt_version_mismatch"
2879
+ : e2ee && ready.data?.e2eeDirectTextVersion !== Number(e2eeVersion)
2880
+ ? "relay_ready_e2ee_version_mismatch"
2881
+ : e2ee && ready.data?.identityKeyId !== e2eeIdentityKeyId
2882
+ ? "relay_ready_e2ee_identity_mismatch"
2883
+ : null;
2884
+ if (readyMismatchCode) {
2885
+ await stream.close().catch(() => {});
2886
+ throw new HelloLynkLocalMessagingError(readyMismatchCode);
2887
+ }
2888
+ return {
2889
+ ready: ready.data,
2890
+ async nextIncoming() {
2891
+ const event = await stream.nextEvent();
2892
+ if (event.event === "closed") {
2893
+ const reason = [
2894
+ "runtime_session_expired",
2895
+ "runtime_session_revoked",
2896
+ "runtime_session_missing",
2897
+ "device_credential_revoked",
2898
+ "upstream_authority_inactive",
2899
+ "relay_backpressure_limit",
2900
+ "relay_connection_unwritable",
2901
+ "relay_write_failed",
2902
+ "relay_heartbeat_failed",
2903
+ ].includes(event.data?.reason) ? event.data.reason : "relay_closed";
2904
+ throw new HelloLynkLocalMessagingError(`relay_event_stream_closed_${reason}`, {
2905
+ retryable: reason === "runtime_session_expired" || reason.startsWith("relay_"),
2906
+ reason,
2907
+ });
2908
+ }
2909
+ if (
2910
+ event.event !== "message" ||
2911
+ event.data?.recipientPrincipalId !== principalId ||
2912
+ event.data?.messageType !== "text"
2913
+ ) {
2914
+ throw new HelloLynkLocalMessagingError("relay_event_contract_mismatch");
2915
+ }
2916
+ const ackFields = ["ackContract", "deliveryAttemptId", "targetConnectionId", "ackDeadlineAt"];
2917
+ const ackFieldCount = ackFields.filter((field) => Object.hasOwn(event.data, field)).length;
2918
+ const legacyContract = ackFieldCount === 0;
2919
+ const persistedAckContract = (
2920
+ ackFieldCount === ackFields.length &&
2921
+ event.data.ackContract === RECEIVER_LOCAL_STORE_CONTRACT &&
2922
+ typeof event.data.deliveryAttemptId === "string" &&
2923
+ typeof event.data.targetConnectionId === "string" &&
2924
+ typeof event.data.ackDeadlineAt === "string" &&
2925
+ Number.isFinite(Date.parse(event.data.ackDeadlineAt))
2926
+ );
2927
+ if (!legacyContract && !persistedAckContract) {
2928
+ throw new HelloLynkLocalMessagingError("relay_event_contract_mismatch");
2929
+ }
2930
+ const hasEnvelope = event.data?.e2eeEnvelope != null;
2931
+ const hasPlaintext = event.data?.text != null;
2932
+ if (hasEnvelope === hasPlaintext) {
2933
+ throw new HelloLynkLocalMessagingError("relay_event_security_contract_mismatch");
2934
+ }
2935
+ if (!hasEnvelope && e2eeMode === "required") {
2936
+ throw new HelloLynkLocalMessagingError("e2ee_plaintext_event_rejected");
2937
+ }
2938
+ let localEvent = event.data;
2939
+ let stored = null;
2940
+ let opened = null;
2941
+ if (hasEnvelope) {
2942
+ if (!e2ee) throw new HelloLynkLocalMessagingError("e2ee_receiver_unavailable");
2943
+ const outerDigest = safeSha256(event.data?.envelopeDigest);
2944
+ const outerSenderIdentityKeyId = safeId(
2945
+ event.data?.senderIdentityKeyId,
2946
+ "e2ee_sender_identity_key_id_invalid",
2947
+ );
2948
+ const replay = typeof store.findIncomingE2eeReplay === "function"
2949
+ ? await store.findIncomingE2eeReplay({
2950
+ senderIdentityKeyId: outerSenderIdentityKeyId,
2951
+ clientMessageId: event.data.clientMessageId,
2952
+ envelopeDigest: outerDigest,
2953
+ })
2954
+ : null;
2955
+ if (replay) {
2956
+ const replayBinding = getMessageConversationBinding(replay, { principalId });
2957
+ if (replayBinding) {
2958
+ const eventBinding = deriveInstanceConversationBinding({
2959
+ ...event.data.e2eeEnvelope?.protected, e2eeVersion: 2,
2960
+ }, { direction: "incoming", principalId });
2961
+ if (replayBinding.localDelegateId !== delegateId
2962
+ || instanceConversationKey(replayBinding) !== instanceConversationKey(eventBinding)) {
2963
+ throw new HelloLynkLocalMessagingError("local_incoming_message_conflict", { httpStatus: 409 });
2964
+ }
2965
+ }
2966
+ stored = { message: replay, duplicate: true };
2967
+ localEvent = {
2968
+ envelopeDigest: outerDigest,
2969
+ senderIdentityKeyId: outerSenderIdentityKeyId,
2970
+ };
2971
+ if (typeof e2ee.commitIncomingReplay === "function") {
2972
+ await e2ee.commitIncomingReplay({
2973
+ event: event.data,
2974
+ storedMessage: replay,
2975
+ });
2976
+ }
2977
+ } else {
2978
+ opened = await e2ee.openIncoming({
2979
+ event: event.data,
2980
+ recipientPrincipalId: principalId,
2981
+ recipientDelegateId: delegateId,
2982
+ });
2983
+ const envelopeDigest = safeSha256(opened?.envelopeDigest || outerDigest);
2984
+ if (outerDigest !== envelopeDigest) {
2985
+ throw new HelloLynkLocalMessagingError("e2ee_envelope_digest_mismatch");
2986
+ }
2987
+ localEvent = {
2988
+ eventId: event.data.eventId,
2989
+ clientMessageId: event.data.clientMessageId,
2990
+ messageType: "text",
2991
+ senderPrincipalId: opened?.senderPrincipalId,
2992
+ senderDelegateId: opened?.senderDelegateId,
2993
+ authorizationScope: opened?.authorizationScope,
2994
+ intentSource: opened?.intentSource,
2995
+ ...(opened?.replyToClientMessageId ? { replyToClientMessageId: opened.replyToClientMessageId } : {}),
2996
+ ...(opened?.targetConversationId ? { targetConversationId: opened.targetConversationId } : {}),
2997
+ senderHelloLynkId: event.data.senderHelloLynkId,
2998
+ senderPrincipalDisplayName: event.data.senderPrincipalDisplayName,
2999
+ senderDelegateDisplayName: event.data.senderDelegateDisplayName,
3000
+ recipientPrincipalId: event.data.recipientPrincipalId,
3001
+ conversationBinding: opened?.e2eeVersion === 2
3002
+ ? deriveInstanceConversationBinding(opened, { direction: "incoming", principalId })
3003
+ : null,
3004
+ text: safeText(opened?.text),
3005
+ envelopeDigest,
3006
+ senderIdentityKeyId: opened?.senderIdentityKeyId || event.data.senderIdentityKeyId,
3007
+ recipientIdentityKeyId: opened?.recipientIdentityKeyId || event.data.recipientIdentityKeyId,
3008
+ };
3009
+ }
3010
+ }
3011
+ if (!stored) stored = await store.addIncoming(localEvent);
3012
+ if (!stored.duplicate && typeof opened?.commit === "function") await opened.commit();
3013
+ if (persistedAckContract) {
3014
+ await stream.acknowledgePersisted({
3015
+ ...(stored.message.replyTargetDeleted ? { replyTargetDeleted: true } : {}),
3016
+ deliveryAttemptId: event.data.deliveryAttemptId,
3017
+ eventId: event.data.eventId,
3018
+ clientMessageId: event.data.clientMessageId,
3019
+ connectionId: event.data.targetConnectionId,
3020
+ envelopeDigest: hasEnvelope ? localEvent.envelopeDigest : null,
3021
+ ...(hasEnvelope && Number(event.data?.schemaVersion) === 2 ? {
3022
+ e2eeVersion: 2,
3023
+ routeKind: event.data.routeKind,
3024
+ recipientDelegateId: event.data.recipientDelegateId,
3025
+ recipientDelegateAuthorizationVersion: event.data.recipientDelegateAuthorizationVersion,
3026
+ recipientHostId: event.data.recipientHostId,
3027
+ recipientRouteVersion: event.data.recipientRouteVersion,
3028
+ recipientDeviceId: event.data.recipientDeviceId,
3029
+ recipientIdentityKeyId: event.data.recipientIdentityKeyId,
3030
+ recipientPreKeyId: event.data.recipientPreKeyId,
3031
+ } : {}),
3032
+ });
3033
+ if (hasEnvelope && typeof e2ee?.replenishPreKeyInventory === "function") {
3034
+ void Promise.resolve().then(() => e2ee.replenishPreKeyInventory()).catch(() => {});
3035
+ }
3036
+ }
3037
+ return stored;
3038
+ },
3039
+ close: () => stream.close(),
3040
+ };
3041
+ },
3042
+ };
3043
+ }