@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/LICENSE +92 -0
- package/README.md +86 -2
- package/bin/hellolynk-client-setup.mjs +2748 -0
- package/bin/hellolynk-mcp-config-shape.mjs +23 -0
- package/hellolynk-client-pack-manifest.json +619 -0
- package/hellolynk-client-pack-pin.json +4 -0
- package/package.json +58 -4
- package/plugins/hellolynk-codex/.app.json +3 -0
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
- package/plugins/hellolynk-codex/.mcp.json +19 -0
- package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/package.json +11 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +160 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
- package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
- package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
- package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
- package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
- package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
- package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
- package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
- package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
- package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
- package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
- package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
- package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
- package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
- package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
- package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
- package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
- package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
- 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
|
+
}
|