@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.
Files changed (123) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/LICENSE +92 -0
  3. package/README.md +86 -2
  4. package/bin/hellolynk-client-setup.mjs +2748 -0
  5. package/bin/hellolynk-mcp-config-shape.mjs +23 -0
  6. package/hellolynk-client-pack-manifest.json +619 -0
  7. package/hellolynk-client-pack-pin.json +4 -0
  8. package/package.json +58 -4
  9. package/plugins/hellolynk-codex/.app.json +3 -0
  10. package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
  11. package/plugins/hellolynk-codex/.mcp.json +19 -0
  12. package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
  13. package/plugins/hellolynk-codex/package.json +11 -0
  14. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
  15. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
  16. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
  17. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
  18. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
  19. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
  20. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  21. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
  22. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
  23. package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  24. package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
  25. package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
  26. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
  27. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
  28. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
  29. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
  30. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
  31. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
  32. package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
  33. package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
  34. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
  35. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
  36. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
  37. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
  38. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
  39. package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
  40. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
  41. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
  42. package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
  43. package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
  44. package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
  45. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
  46. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
  47. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
  48. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
  49. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
  50. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
  51. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
  52. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
  53. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
  54. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
  55. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
  56. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
  57. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
  58. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
  59. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
  60. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
  61. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
  62. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
  63. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  64. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
  65. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
  66. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
  67. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
  68. package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
  69. package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
  70. package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +160 -0
  71. package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
  72. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
  73. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
  74. package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
  75. package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
  76. package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
  77. package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
  78. package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
  79. package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
  80. package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
  81. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  82. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
  83. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
  84. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
  85. package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
  86. package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
  87. package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
  88. package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
  89. package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
  90. package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
  91. package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
  92. package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
  93. package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
  94. package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
  95. package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
  96. package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
  97. package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
  98. package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
  99. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
  100. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
  101. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
  102. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
  103. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
  104. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
  105. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
  106. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
  107. package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
  108. package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
  109. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
  110. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
  111. package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
  112. package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
  113. package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
  114. package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
  115. package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
  116. package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
  117. package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
  118. package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
  119. package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
  120. package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
  121. package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
  122. package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
  123. package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
@@ -0,0 +1,1964 @@
1
+ import runtimeSafety from "./hellolynk-runtime-safety.cjs";
2
+ import { encodeNativeContent, decodeNativeContent } from "./hellolynk-native-conversations.mjs";
3
+ import { createHash } from "node:crypto";
4
+
5
+ import { normalizeHelloLynkBaseUrl } from "./hellolynk-client-core.mjs";
6
+ import { createPlatformCredentialStore } from "./hellolynk-platform-credential-store.mjs";
7
+ import { sealContactRequestIntroEnvelope } from "./hellolynk-contact-request-e2ee.mjs";
8
+ import {
9
+ DIRECT_TEXT_E2EE_AUTHORIZATION_SCOPE,
10
+ DIRECT_TEXT_E2EE_ALLOWED_INTENT_SOURCES,
11
+ DIRECT_TEXT_E2EE_MAX_TTL_MS,
12
+ createDirectTextDeviceKeyBundle,
13
+ createOneTimePreKey,
14
+ digestDirectTextEnvelope,
15
+ digestDirectTextEnvelopeV2,
16
+ openDirectTextEnvelope,
17
+ openDirectTextEnvelopeV2,
18
+ publicDirectTextDeviceIdentity,
19
+ publicOneTimePreKey,
20
+ sealDirectTextEnvelope,
21
+ sealDirectTextEnvelopeV2,
22
+ verifyDeviceIdentityRegistration,
23
+ verifyOneTimePreKeyRegistration,
24
+ } from "./hellolynk-direct-text-e2ee.mjs";
25
+
26
+ export const DIRECT_TEXT_E2EE_PRIVATE_STATE_SCHEMA_VERSION = 7;
27
+ const STATE_SCHEMA_VERSION = DIRECT_TEXT_E2EE_PRIVATE_STATE_SCHEMA_VERSION;
28
+ const DEFAULT_PREKEY_COUNT = 8;
29
+ const MAX_PREKEY_COUNT = 8;
30
+ const MAX_LOCAL_PREKEYS = 64;
31
+ const MAX_PEER_PINS = 128;
32
+ const MAX_PEER_AUTHORIZATIONS = 128;
33
+ const MAX_PENDING_PEER_CLAIMS = 32;
34
+ const MAX_CONSUMED_RECORDS = 512;
35
+ const MAX_PREPARED_OUTGOING = 64;
36
+ const MAX_JSON_RESPONSE_BYTES = 256 * 1024;
37
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
38
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}$/;
39
+ const SAFE_DIGEST = /^[a-f0-9]{64}$/;
40
+ const SAFETY_CODE = /^[A-F0-9]{4}(?:-[A-F0-9]{4}){11}$/;
41
+ const OUTER_PROTECTED_BINDINGS = Object.freeze([
42
+ ["clientMessageId", "clientMessageId"],
43
+ ["senderPrincipalId", "senderPrincipalId"],
44
+ ["senderHelloLynkId", "senderHelloLynkId"],
45
+ ["senderAgentId", "senderAgentId"],
46
+ ["senderDelegateId", "senderDelegateId"],
47
+ ["senderDeviceId", "senderDeviceId"],
48
+ ["senderIdentityKeyId", "senderIdentityKeyId"],
49
+ ["recipientPrincipalId", "recipientPrincipalId"],
50
+ ["recipientHelloLynkId", "recipientHelloLynkId"],
51
+ ["recipientDeviceId", "recipientDeviceId"],
52
+ ["recipientIdentityKeyId", "recipientIdentityKeyId"],
53
+ ["recipientPreKeyId", "recipientPreKeyId"],
54
+ ["messageType", "messageType"],
55
+ ]);
56
+ const OUTER_PROTECTED_BINDINGS_V2 = Object.freeze([
57
+ ["clientMessageId", "clientMessageId"], ["routeKind", "routeKind"],
58
+ ["senderPrincipalId", "senderPrincipalId"], ["senderHelloLynkId", "senderHelloLynkId"],
59
+ ["senderAgentId", "senderAgentId"], ["senderDelegateId", "senderDelegateId"],
60
+ ["senderDelegateAuthorizationVersion", "senderDelegateAuthorizationVersion"],
61
+ ["senderHostId", "senderHostId"], ["senderDeviceId", "senderDeviceId"],
62
+ ["senderIdentityKeyId", "senderIdentityKeyId"],
63
+ ["recipientPrincipalId", "recipientPrincipalId"], ["recipientHelloLynkId", "recipientHelloLynkId"],
64
+ ["recipientDelegateId", "recipientDelegateId"],
65
+ ["recipientDelegateAuthorizationVersion", "recipientDelegateAuthorizationVersion"],
66
+ ["recipientHostId", "recipientHostId"], ["recipientRouteVersion", "recipientRouteVersion"],
67
+ ["recipientDeviceId", "recipientDeviceId"], ["recipientIdentityKeyId", "recipientIdentityKeyId"],
68
+ ["recipientPreKeyId", "recipientPreKeyId"], ["messageType", "messageType"],
69
+ ]);
70
+
71
+ export class HelloLynkDirectTextE2eeClientError extends Error {
72
+ constructor(code, options = {}) {
73
+ super(code, options.cause ? { cause: options.cause } : undefined);
74
+ this.name = "HelloLynkDirectTextE2eeClientError";
75
+ this.code = code;
76
+ this.httpStatus = Number.isInteger(options.httpStatus) ? options.httpStatus : null;
77
+ this.retryable = options.retryable === true;
78
+ this.retryAt = options.retryAt || null;
79
+ this.peerIdentityVerification = options.peerIdentityVerification
80
+ ? Object.freeze({ ...options.peerIdentityVerification })
81
+ : null;
82
+ }
83
+ }
84
+
85
+ export function normalizeDirectTextSafetyCode(value) {
86
+ const normalized = String(value || "").trim().toUpperCase().replace(/[\s:]+/gu, "-");
87
+ if (!SAFETY_CODE.test(normalized)) {
88
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_peer_safety_code_invalid");
89
+ }
90
+ return normalized;
91
+ }
92
+
93
+ export function directTextDeviceSafetyCode(input) {
94
+ assertPlainObject(input, "e2ee_peer_identity_invalid");
95
+ const canonical = JSON.stringify({
96
+ domain: "hellolynk/direct-text/device-safety-code/v1",
97
+ hellolynkId: safeId(input.hellolynkId, "e2ee_peer_hellolynk_id_invalid"),
98
+ deviceId: safeId(input.deviceId, "e2ee_peer_device_id_invalid"),
99
+ identityKeyId: safeId(input.identityKeyId, "e2ee_peer_identity_key_id_invalid"),
100
+ publicKey: String(input.publicKey || ""),
101
+ });
102
+ const digest = createHash("sha256").update(canonical, "utf8").digest("hex").slice(0, 48).toUpperCase();
103
+ return digest.match(/.{4}/g).join("-");
104
+ }
105
+
106
+ export async function createDirectTextE2eeClientContext(options = {}) {
107
+ assertPlainObject(options, "e2ee_client_options_invalid");
108
+ const profileId = safeProfileId(options.profileId);
109
+ const e2eeVersion = options.e2eeVersion == null ? 1 : Number(options.e2eeVersion);
110
+ if (![1, 2].includes(e2eeVersion)) throw new HelloLynkDirectTextE2eeClientError("e2ee_version_unsupported");
111
+ const owner = Object.freeze({
112
+ principalId: safeId(options.principalId, "e2ee_owner_principal_invalid"),
113
+ hellolynkId: safeId(options.hellolynkId, "e2ee_owner_hellolynk_id_invalid"),
114
+ agentId: safeId(options.agentId, "e2ee_owner_agent_invalid"),
115
+ delegateId: safeId(options.delegateId, "e2ee_owner_delegate_invalid"),
116
+ deviceId: safeId(options.deviceId, "e2ee_owner_device_invalid"),
117
+ ...(e2eeVersion === 2 ? {
118
+ hostId: safeId(options.hostId, "e2ee_owner_host_invalid"),
119
+ authorizationVersion: Number(options.authorizationVersion),
120
+ } : {}),
121
+ });
122
+ if (owner.principalId === owner.agentId || owner.principalId === owner.delegateId) {
123
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_owner_identity_collapsed");
124
+ }
125
+ if (e2eeVersion === 2 && (
126
+ !Number.isSafeInteger(owner.authorizationVersion) || owner.authorizationVersion < 1
127
+ )) throw new HelloLynkDirectTextE2eeClientError("e2ee_owner_route_context_required");
128
+ if (e2eeVersion === 1 && (options.hostId != null || options.authorizationVersion != null)) {
129
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_owner_route_context_unexpected");
130
+ }
131
+ const baseUrl = normalizeHelloLynkBaseUrl(options.baseUrl, {
132
+ allowLoopbackHttp: options.allowLoopbackHttp === true,
133
+ }).replace(/\/api\/v1$/, "");
134
+ const fetchImpl = options.fetchImpl || runtimeSafety.helloLynkNetworkFetch;
135
+ const sessionProvider = options.sessionProvider;
136
+ const credentialStore = options.credentialStore || createPlatformCredentialStore(options.credentialStoreOptions);
137
+ const privateKeys = credentialStore?.directTextPrivateKeys;
138
+ const stateLock = credentialStore?.runtimeSessions;
139
+ const now = typeof options.now === "function" ? options.now : () => Date.now();
140
+ const sameHostClaimProvider = options.sameHostClaimProvider;
141
+ const preKeyCount = options.preKeyCount == null ? DEFAULT_PREKEY_COUNT : Number(options.preKeyCount);
142
+ const requestTimeoutMs = options.requestTimeoutMs == null ? DEFAULT_REQUEST_TIMEOUT_MS : Number(options.requestTimeoutMs);
143
+ const localClaimTimeoutMs = options.localClaimTimeoutMs ?? requestTimeoutMs;
144
+ if (!Number.isSafeInteger(localClaimTimeoutMs) || localClaimTimeoutMs < 1 || localClaimTimeoutMs > 30_000) {
145
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_local_claim_timeout_invalid");
146
+ }
147
+ if (!Number.isSafeInteger(requestTimeoutMs) || requestTimeoutMs < 1 || requestTimeoutMs > 10_000) {
148
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_request_timeout_invalid");
149
+ }
150
+ if (typeof fetchImpl !== "function") throw new HelloLynkDirectTextE2eeClientError("e2ee_fetch_unavailable");
151
+ if (!sessionProvider || typeof sessionProvider.get !== "function") {
152
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_runtime_session_provider_required");
153
+ }
154
+ if (!privateKeys || typeof privateKeys.load !== "function" || typeof privateKeys.save !== "function") {
155
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_key_store_unavailable");
156
+ }
157
+ if (!stateLock || typeof stateLock.withLock !== "function") {
158
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_cross_process_lock_unavailable");
159
+ }
160
+
161
+ if (sameHostClaimProvider != null && typeof sameHostClaimProvider !== "function") {
162
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_same_host_claim_provider_invalid");
163
+ }
164
+ if (!Number.isSafeInteger(preKeyCount) || preKeyCount < 1 || preKeyCount > MAX_PREKEY_COUNT) {
165
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_prekey_count_invalid");
166
+ }
167
+
168
+ const withState = (operation, signal) => withRequestSignal(() => stateLock.withLock(profileId, async () => {
169
+ assertRequestActive(signal);
170
+ let state;
171
+ try {
172
+ state = await privateKeys.load(profileId);
173
+ } catch (error) {
174
+ throw clientFailure(error, "e2ee_private_key_load_failed");
175
+ }
176
+ assertRequestActive(signal);
177
+ const save = async () => {
178
+ assertRequestActive(signal);
179
+ // An atomic save already in progress keeps the physical profile lock
180
+ // until it settles, even if the caller has stopped waiting after abort.
181
+ await saveState(privateKeys, profileId, state);
182
+ assertRequestActive(signal);
183
+ };
184
+ if (state == null) {
185
+ state = createInitialState(profileId, owner, preKeyCount, now());
186
+ await save();
187
+ } else {
188
+ const normalized = normalizeState(state, profileId, owner);
189
+ state = normalized.state;
190
+ if (normalized.migrated) await save();
191
+ }
192
+ const result = await operation(state, save);
193
+ assertRequestActive(signal);
194
+ return result;
195
+ }), signal);
196
+
197
+ function assertRequestActive(signal) {
198
+ if (signal?.aborted) throw new HelloLynkDirectTextE2eeClientError(
199
+ signal.reason?.name === "TimeoutError" || signal.reason?.code === "e2ee_request_timeout"
200
+ ? "e2ee_request_timeout" : "e2ee_request_cancelled",
201
+ { retryable: true },
202
+ );
203
+ }
204
+
205
+ async function withRequestSignal(operation, signal) {
206
+ assertRequestActive(signal);
207
+ if (!signal) return operation();
208
+ let onAbort;
209
+ const interrupted = new Promise((_, reject) => {
210
+ onAbort = () => { try { assertRequestActive(signal); } catch (error) { reject(error); } };
211
+ signal.addEventListener("abort", onAbort, { once: true });
212
+ });
213
+ try {
214
+ return await Promise.race([Promise.resolve().then(() => {
215
+ assertRequestActive(signal);
216
+ return operation();
217
+ }), interrupted]);
218
+ } finally {
219
+ signal.removeEventListener("abort", onAbort);
220
+ }
221
+ }
222
+
223
+ async function runtimeHeaders() {
224
+ let session;
225
+ try {
226
+ session = await sessionProvider.get({
227
+ expectedPrincipalId: owner.principalId,
228
+ expectedAgentId: owner.agentId,
229
+ });
230
+ } catch (error) {
231
+ throw clientFailure(error, "e2ee_runtime_session_unavailable");
232
+ }
233
+ if (
234
+ safeId(session?.principalId, "e2ee_runtime_session_identity_invalid") !== owner.principalId
235
+ || safeId(session?.agentId, "e2ee_runtime_session_identity_invalid") !== owner.agentId
236
+ || safeId(session?.deviceId, "e2ee_runtime_session_identity_invalid") !== owner.deviceId
237
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_runtime_session_identity_mismatch");
238
+ if (e2eeVersion === 2 && (
239
+ safeId(session?.delegateId, "e2ee_runtime_session_identity_invalid") !== owner.delegateId
240
+ || safeId(session?.hostId, "e2ee_runtime_session_identity_invalid") !== owner.hostId
241
+ || Number(session?.authorizationVersion) !== owner.authorizationVersion
242
+ )) throw new HelloLynkDirectTextE2eeClientError("e2ee_runtime_session_route_mismatch");
243
+ const sessionId = safeId(session?.sessionId || session?.id, "e2ee_runtime_session_invalid");
244
+ const secret = String(session?.sessionSecret || session?.secret || "");
245
+ if (!/^runtime_[a-f0-9]{48}$/.test(secret)) {
246
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_runtime_session_invalid");
247
+ }
248
+ return {
249
+ "x-hellolynk-runtime-session-id": sessionId,
250
+ "x-hellolynk-runtime-session-secret": secret,
251
+ ...(e2eeVersion === 2 ? { "x-hellolynk-e2ee-version": "2" } : {}),
252
+ };
253
+ }
254
+
255
+ async function requestJson(path, method, body, acceptedStatuses, authorizedHeaders = null, signal) {
256
+ let response;
257
+ const controller = new AbortController();
258
+ const abort = () => controller.abort(new HelloLynkDirectTextE2eeClientError(
259
+ signal?.reason?.name === "TimeoutError" ? "e2ee_request_timeout" : "e2ee_request_cancelled",
260
+ { retryable: true },
261
+ ));
262
+ signal?.addEventListener("abort", abort, { once: true });
263
+ if (signal?.aborted) abort();
264
+ const timer = setTimeout(() => controller.abort(new HelloLynkDirectTextE2eeClientError("e2ee_request_timeout", { retryable: true })), requestTimeoutMs);
265
+ let onAbort;
266
+ const interrupted = new Promise((_, reject) => {
267
+ onAbort = () => reject(controller.signal.reason);
268
+ controller.signal.addEventListener("abort", onAbort, { once: true });
269
+ if (controller.signal.aborted) onAbort();
270
+ });
271
+ void interrupted.catch(() => {});
272
+ try {
273
+ if (controller.signal.aborted) throw controller.signal.reason;
274
+ response = await Promise.race([fetchImpl(`${baseUrl}${path}`, {
275
+ method,
276
+ headers: {
277
+ accept: "application/json",
278
+ "content-type": "application/json",
279
+ ...(authorizedHeaders || await runtimeHeaders()),
280
+ },
281
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
282
+ redirect: "error",
283
+ signal: controller.signal,
284
+ }), interrupted]);
285
+ const data = await Promise.race([readBoundedJson(response), interrupted]);
286
+ if (!acceptedStatuses.has(response.status)) {
287
+ const code = /^[a-z][a-z0-9_]{0,63}$/.test(String(data?.error || ""))
288
+ ? data.error : `e2ee_http_${response.status}`;
289
+ throw new HelloLynkDirectTextE2eeClientError(code, {
290
+ httpStatus: response.status,
291
+ retryable: response.status >= 500 || [408, 425, 429].includes(response.status),
292
+ });
293
+ }
294
+ return data;
295
+ } catch (error) {
296
+ if (error instanceof HelloLynkDirectTextE2eeClientError) throw error;
297
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_transport_error", { cause: error, retryable: true });
298
+ } finally {
299
+ clearTimeout(timer);
300
+ signal?.removeEventListener("abort", abort);
301
+ controller.signal.removeEventListener("abort", onAbort);
302
+ if (controller.signal.aborted) {
303
+ try { void response?.body?.cancel?.().catch(() => {}); } catch {}
304
+ }
305
+ }
306
+ }
307
+
308
+ async function registerState(state, save, authorizedHeaders, signal) {
309
+ const identity = publicDirectTextDeviceIdentity(state.deviceKeyBundle);
310
+ const identityResult = await requestJson(
311
+ "/api/v1/e2ee/device-identity",
312
+ "PUT",
313
+ { identity },
314
+ new Set([200, 201]),
315
+ authorizedHeaders,
316
+ signal,
317
+ );
318
+ requireExactFields(identityResult, ["ok", "identity", "deviceId", "state", "idempotent"], "e2ee_identity_response_invalid");
319
+ if (
320
+ identityResult.ok !== true || identityResult.state !== "active"
321
+ || identityResult.deviceId !== owner.deviceId
322
+ || JSON.stringify(identityResult.identity) !== JSON.stringify(identity)
323
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_identity_response_mismatch");
324
+
325
+ const pending = state.preKeys.filter((item) => item.state === "pending_registration");
326
+ if (pending.length > 0) {
327
+ const oneTimePreKeys = pending.map((item) => publicOneTimePreKey(item.privateKeyBundle, state.deviceKeyBundle));
328
+ const result = await requestJson(
329
+ "/api/v1/e2ee/one-time-prekeys",
330
+ "POST",
331
+ { identityKeyId: state.deviceKeyBundle.keyId, oneTimePreKeys },
332
+ new Set([200]),
333
+ authorizedHeaders,
334
+ signal,
335
+ );
336
+ requireExactFields(result, ["ok", "identityKeyId", "inserted", "idempotent", "available"], "e2ee_prekey_response_invalid");
337
+ if (
338
+ result.ok !== true || result.identityKeyId !== state.deviceKeyBundle.keyId
339
+ || !Number.isSafeInteger(result.inserted) || !Number.isSafeInteger(result.idempotent)
340
+ || result.inserted + result.idempotent !== pending.length || !Number.isSafeInteger(result.available)
341
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_prekey_response_mismatch");
342
+ const registeredAt = new Date(now()).toISOString();
343
+ for (const item of pending) {
344
+ item.state = "registered";
345
+ item.registeredAt = registeredAt;
346
+ }
347
+ await save();
348
+ }
349
+ }
350
+
351
+ async function ensurePreKeyInventoryWithHeaders(authorizedHeaders, signal) {
352
+ return withState(async (state, save) => {
353
+ // Bound the whole network sequence as well as each request: several slow
354
+ // successful stages must not keep this profile locked past the lock wait.
355
+ const inventorySignal = AbortSignal.any([
356
+ AbortSignal.timeout(Math.min(requestTimeoutMs * 2, 10_000)),
357
+ ...(signal ? [signal] : []),
358
+ ]);
359
+ await registerState(state, save, authorizedHeaders, inventorySignal);
360
+ const inventory = validateInventoryResponse(await requestJson(
361
+ "/api/v1/e2ee/one-time-prekeys",
362
+ "GET",
363
+ undefined,
364
+ new Set([200]),
365
+ authorizedHeaders,
366
+ inventorySignal,
367
+ ), now());
368
+ const result = reconcilePreKeyInventory(state, inventory, preKeyCount, now());
369
+ if (result.changed) await save();
370
+ if (result.generated > 0) await registerState(state, save, authorizedHeaders, inventorySignal);
371
+ return Object.freeze({
372
+ available: result.available + result.generated,
373
+ generated: result.generated,
374
+ retainedUsed: result.retainedUsed,
375
+ });
376
+ });
377
+ }
378
+
379
+ async function ensurePreKeyInventory({ signal } = {}) {
380
+ return ensurePreKeyInventoryWithHeaders(await runtimeHeaders(), signal);
381
+ }
382
+
383
+ // The neutral Host can initialize its protected local identity while offline.
384
+ // It provisions relay keys before registering its cloud receive endpoint.
385
+ let preKeyInventoryStatus = Object.freeze({ status: "deferred" });
386
+ if (options.deferCloudRegistration !== true) {
387
+ preKeyInventoryStatus = Object.freeze({ status: "ready" });
388
+ try {
389
+ await ensurePreKeyInventory({ signal: options.signal });
390
+ } catch (error) {
391
+ // Retained used keys can temporarily fill the pool. Keep the receiver
392
+ // available for maintenance after expiry.
393
+ if (error?.code !== "e2ee_local_prekey_capacity_exceeded") throw error;
394
+ preKeyInventoryStatus = Object.freeze({ status: "retrying", errorCode: error.code, retryAt: error.retryAt });
395
+ }
396
+ }
397
+ const { identityKeyId, localDeviceSafetyCode } = await withState(async (state) => ({
398
+ identityKeyId: state.deviceKeyBundle.keyId,
399
+ localDeviceSafetyCode: directTextDeviceSafetyCode({
400
+ hellolynkId: owner.hellolynkId,
401
+ deviceId: owner.deviceId,
402
+ identityKeyId: state.deviceKeyBundle.keyId,
403
+ publicKey: state.deviceKeyBundle.publicKey,
404
+ }),
405
+ }));
406
+
407
+
408
+ async function prepareOutgoing(input, { signal, persistPrepared = true } = {}) {
409
+ if (e2eeVersion === 2) return prepareOutgoingV2(input, { signal, persistPrepared });
410
+ requireExactFields(input, [
411
+ "recipientHelloLynkId", "text", "clientMessageId", "senderPrincipalId", "senderDelegateId", "intentSource",
412
+ ], "e2ee_prepare_input_invalid");
413
+ const recipientHelloLynkId = safeId(input.recipientHelloLynkId, "e2ee_recipient_hellolynk_id_invalid");
414
+ const clientMessageId = safeId(input.clientMessageId, "e2ee_client_message_id_invalid");
415
+ if (
416
+ input.senderPrincipalId !== owner.principalId || input.senderDelegateId !== owner.delegateId
417
+ || !DIRECT_TEXT_E2EE_ALLOWED_INTENT_SOURCES.includes(input.intentSource)
418
+ || typeof input.text !== "string"
419
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_prepare_identity_mismatch");
420
+ const requestDigest = digestRequest({ ...input, recipientHelloLynkId, clientMessageId });
421
+ const authorizedHeaders = await runtimeHeaders();
422
+ return withState(async (state, save) => {
423
+ const prunedPendingClaims = pruneExpiredPendingPeerClaims(state, now());
424
+ const existing = state.preparedOutgoing.find((item) => (
425
+ item.clientMessageId === clientMessageId && item.recipientHelloLynkId === recipientHelloLynkId
426
+ ));
427
+ if (existing) {
428
+ if (existing.requestDigest !== requestDigest) {
429
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_prepared_message_conflict");
430
+ }
431
+ requirePreparedPeerTrust(state, existing);
432
+ if (prunedPendingClaims) await save();
433
+ return publicPrepared(existing);
434
+ }
435
+ if (state.preparedOutgoing.some((item) => item.clientMessageId === clientMessageId)) {
436
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_prepared_message_conflict");
437
+ }
438
+ if (state.preparedOutgoing.length >= MAX_PREPARED_OUTGOING) {
439
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_prepared_message_capacity_exceeded");
440
+ }
441
+ if (state.pendingPeerClaims.some((item) => (
442
+ item.clientMessageId === clientMessageId && item.recipientHelloLynkId !== recipientHelloLynkId
443
+ ))) throw new HelloLynkDirectTextE2eeClientError("e2ee_prepared_message_conflict");
444
+ const pendingClaim = state.pendingPeerClaims.find((item) => (
445
+ item.recipientHelloLynkId === recipientHelloLynkId && item.clientMessageId === clientMessageId
446
+ ));
447
+ const claim = pendingClaim?.claim || validateClaimResponse(await requestJson(
448
+ "/api/v1/e2ee/one-time-prekeys/claim",
449
+ "POST",
450
+ { recipientHelloLynkId, clientMessageId },
451
+ new Set([200]),
452
+ authorizedHeaders,
453
+ signal,
454
+ ), recipientHelloLynkId, now());
455
+ observePeerIdentity(state, {
456
+ peerHelloLynkId: claim.recipientHelloLynkId,
457
+ peerPrincipalId: claim.recipientPrincipalId,
458
+ peerDeviceId: claim.recipientDeviceId,
459
+ identity: claim.identity,
460
+ observedAt: claim.claimedAt,
461
+ }, now());
462
+ if (pendingClaim) {
463
+ state.pendingPeerClaims.splice(state.pendingPeerClaims.indexOf(pendingClaim), 1);
464
+ }
465
+ const createdAtMs = now();
466
+ const expiresAtMs = Math.min(createdAtMs + DIRECT_TEXT_E2EE_MAX_TTL_MS, Date.parse(claim.expiresAt));
467
+ if (expiresAtMs <= createdAtMs) throw new HelloLynkDirectTextE2eeClientError("e2ee_claim_expired");
468
+ const envelope = sealDirectTextEnvelope({
469
+ protectedMetadata: {
470
+ clientMessageId,
471
+ senderPrincipalId: owner.principalId,
472
+ senderHelloLynkId: owner.hellolynkId,
473
+ senderAgentId: owner.agentId,
474
+ senderDelegateId: owner.delegateId,
475
+ senderDeviceId: owner.deviceId,
476
+ senderIdentityKeyId: state.deviceKeyBundle.keyId,
477
+ recipientPrincipalId: claim.recipientPrincipalId,
478
+ recipientHelloLynkId: recipientHelloLynkId,
479
+ recipientDeviceId: claim.recipientDeviceId,
480
+ recipientIdentityKeyId: claim.identity.keyId,
481
+ recipientPreKeyId: claim.oneTimePreKey.preKeyId,
482
+ authorizationScope: DIRECT_TEXT_E2EE_AUTHORIZATION_SCOPE,
483
+ intentSource: input.intentSource,
484
+ messageType: "text",
485
+ createdAt: new Date(createdAtMs).toISOString(),
486
+ expiresAt: new Date(expiresAtMs).toISOString(),
487
+ },
488
+ recipientDeviceIdentity: claim.identity,
489
+ recipientOneTimePreKey: claim.oneTimePreKey,
490
+ senderDeviceKeyBundle: state.deviceKeyBundle,
491
+ text: input.text,
492
+ }, { nowMs: createdAtMs });
493
+ const record = {
494
+ e2eeVersion: 1,
495
+ clientMessageId,
496
+ recipientHelloLynkId,
497
+ requestDigest,
498
+ claimId: claim.claimId,
499
+ envelope,
500
+ envelopeDigest: digestDirectTextEnvelope(envelope),
501
+ };
502
+ state.preparedOutgoing.push(record);
503
+ await save();
504
+ return publicPrepared(record);
505
+ });
506
+ }
507
+
508
+ async function prepareOutgoingV2(input, { signal, persistPrepared = true } = {}) {
509
+ assertPlainObject(input, "e2ee_prepare_input_invalid");
510
+ const routeKind = input.routeKind == null ? "principal_inbox" : String(input.routeKind);
511
+ const baseFields = [
512
+ "recipientHelloLynkId", "text", "clientMessageId", "senderPrincipalId", "senderDelegateId", "intentSource",
513
+ ...(Object.hasOwn(input, "replyToClientMessageId") ? ["replyToClientMessageId"] : []),
514
+ ...["targetConversationId", "nativeDirectory"].filter(key => Object.hasOwn(input, key)),
515
+ ];
516
+ if (routeKind === "principal_inbox") {
517
+ requireExactFields(input, input.routeKind == null ? baseFields : [...baseFields, "routeKind"], "e2ee_prepare_input_invalid");
518
+ } else if (routeKind === "owned_delegate") {
519
+ const fields = [...baseFields, "routeKind", "recipientDelegateId", "recipientHostId"];
520
+ if (input.recipientDeviceId != null) fields.push("recipientDeviceId");
521
+ requireExactFields(input, fields, "e2ee_prepare_input_invalid");
522
+ } else if (routeKind === "instance_direct") {
523
+ requireExactFields(input, [...baseFields, "routeKind", "recipientDelegateId"], "e2ee_prepare_input_invalid");
524
+ } else throw new HelloLynkDirectTextE2eeClientError("e2ee_route_kind_invalid");
525
+ const recipientHelloLynkId = safeId(input.recipientHelloLynkId, "e2ee_recipient_hellolynk_id_invalid");
526
+ const clientMessageId = safeId(input.clientMessageId, "e2ee_client_message_id_invalid");
527
+ const replyToClientMessageId = Object.hasOwn(input, "replyToClientMessageId")
528
+ ? safeId(input.replyToClientMessageId, "e2ee_reply_target_invalid") : null;
529
+ if (replyToClientMessageId && (routeKind === "principal_inbox" || input.intentSource !== "human_preauthorized_contact"
530
+ || replyToClientMessageId === clientMessageId)) throw new HelloLynkDirectTextE2eeClientError("e2ee_reply_target_invalid");
531
+ if (
532
+ input.senderPrincipalId !== owner.principalId || input.senderDelegateId !== owner.delegateId
533
+ || !DIRECT_TEXT_E2EE_ALLOWED_INTENT_SOURCES.includes(input.intentSource)
534
+ || typeof input.text !== "string"
535
+ || (routeKind === "owned_delegate" && recipientHelloLynkId !== owner.hellolynkId)
536
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_prepare_identity_mismatch");
537
+ const sealedContent = encodeNativeContent(input);
538
+ const nativeContentVersion = sealedContent !== input.text ? 1 : null;
539
+ const routeTarget = routeKind === "owned_delegate" ? {
540
+ recipientDelegateId: safeId(input.recipientDelegateId, "e2ee_recipient_delegate_invalid"),
541
+ recipientHostId: safeId(input.recipientHostId, "e2ee_recipient_host_invalid"),
542
+ recipientDeviceId: input.recipientDeviceId == null
543
+ ? null : safeId(input.recipientDeviceId, "e2ee_recipient_device_invalid"),
544
+ } : {
545
+ recipientDelegateId: routeKind === "instance_direct"
546
+ ? safeId(input.recipientDelegateId, "e2ee_recipient_delegate_invalid") : null,
547
+ recipientHostId: null,
548
+ recipientDeviceId: null,
549
+ };
550
+ if (["owned_delegate", "instance_direct"].includes(routeKind)
551
+ && routeTarget.recipientDelegateId === owner.delegateId) {
552
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_route_semantics_invalid");
553
+ }
554
+ const requestDigest = digestRequestV2({
555
+ ...input, routeKind, recipientHelloLynkId, clientMessageId, ...routeTarget,
556
+ });
557
+ const directoryOnly = persistPrepared === false && input.nativeDirectory != null
558
+ && recipientHelloLynkId === owner.hellolynkId && ["owned_delegate", "instance_direct"].includes(routeKind);
559
+ // Directory preparations never hold a user message. Their digest identifies
560
+ // an unused directory claim, rather than a transient query/requestId. Keep
561
+ // the existing pending-record shape readable by the installed prior release.
562
+ const directoryClaimDigest = item => createHash("sha256").update(JSON.stringify([
563
+ "hellolynk/native-directory/pending-claim/v1", owner.delegateId, item.clientMessageId,
564
+ item.recipientHelloLynkId, item.routeKind, item.recipientDelegateId, item.recipientHostId, item.recipientDeviceId,
565
+ ])).digest("hex");
566
+ const reusableDirectoryClaim = item => directoryOnly && item.e2eeVersion === 2
567
+ && item.requestDigest === directoryClaimDigest(item) && item.recipientHelloLynkId === recipientHelloLynkId
568
+ && item.routeKind === routeKind && item.recipientDelegateId === routeTarget.recipientDelegateId
569
+ && (routeKind !== "owned_delegate" || (item.recipientHostId === routeTarget.recipientHostId
570
+ && (!routeTarget.recipientDeviceId || item.recipientDeviceId === routeTarget.recipientDeviceId)));
571
+ const usesSameHostProvider = ["owned_delegate", "instance_direct"].includes(routeKind)
572
+ && recipientHelloLynkId === owner.hellolynkId && sameHostClaimProvider;
573
+ const claimBody = routeKind === "principal_inbox"
574
+ ? { routeKind, recipientHelloLynkId, clientMessageId }
575
+ : routeKind === "instance_direct"
576
+ ? { routeKind, recipientHelloLynkId, recipientDelegateId: routeTarget.recipientDelegateId, clientMessageId }
577
+ : {
578
+ routeKind,
579
+ recipientDelegateId: routeTarget.recipientDelegateId,
580
+ recipientHostId: routeTarget.recipientHostId,
581
+ ...(routeTarget.recipientDeviceId ? { recipientDeviceId: routeTarget.recipientDeviceId } : {}),
582
+ clientMessageId,
583
+ };
584
+ const inspectOutgoing = async (state, save) => {
585
+ const prunedPendingClaims = pruneExpiredPendingPeerClaims(state, now());
586
+ const existing = state.preparedOutgoing.find((item) => (
587
+ item.clientMessageId === clientMessageId && item.recipientHelloLynkId === recipientHelloLynkId
588
+ ));
589
+ if (existing) {
590
+ if (existing.e2eeVersion !== 2 || existing.requestDigest !== requestDigest) {
591
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_prepared_message_conflict");
592
+ }
593
+ requirePreparedPeerTrust(state, existing);
594
+ if (prunedPendingClaims) await save();
595
+ return { prepared: publicPrepared(existing), pendingClaim: null };
596
+ }
597
+ if (state.preparedOutgoing.some((item) => item.clientMessageId === clientMessageId)) {
598
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_prepared_message_conflict");
599
+ }
600
+ if (state.preparedOutgoing.length >= MAX_PREPARED_OUTGOING) {
601
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_prepared_message_capacity_exceeded");
602
+ }
603
+ const pendingClaim = state.pendingPeerClaims.find((item) => (
604
+ item.e2eeVersion === 2 && item.recipientHelloLynkId === recipientHelloLynkId
605
+ && item.clientMessageId === clientMessageId
606
+ )) || state.pendingPeerClaims.find(reusableDirectoryClaim);
607
+ if (pendingClaim && !reusableDirectoryClaim(pendingClaim) && pendingClaim.requestDigest !== requestDigest) {
608
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_prepared_message_conflict");
609
+ }
610
+ return { prepared: null, pendingClaim, prunedPendingClaims };
611
+ };
612
+ let sameHostClaim = null;
613
+ if (usesSameHostProvider) {
614
+ const inspected = await withState(inspectOutgoing, signal);
615
+ if (inspected.prepared) return inspected.prepared;
616
+ if (!inspected.pendingClaim) {
617
+ // The receiver takes its own profile lock. Never call it while holding
618
+ // this sender's lock: simultaneous A->B and B->A must not lock each other.
619
+ // Bound the claim request, not the sender's surrounding private-state
620
+ // I/O as well. Windows credential helpers make those separate stages
621
+ // expensive; caller cancellation still applies to the entire prepare.
622
+ const claimSignal = AbortSignal.any([
623
+ AbortSignal.timeout(localClaimTimeoutMs), ...(signal ? [signal] : []),
624
+ ]);
625
+ sameHostClaim = await withRequestSignal(() => sameHostClaimProvider(Object.freeze({
626
+ ...claimBody,
627
+ // Old local peers reject this field before claiming or receiving a
628
+ // message. Never probe protocol support with a chat control frame.
629
+ ...(nativeContentVersion ? { nativeContentVersion } : {}),
630
+ senderPrincipalId: owner.principalId,
631
+ senderDelegateId: owner.delegateId,
632
+ senderDelegateAuthorizationVersion: owner.authorizationVersion,
633
+ }), { signal: claimSignal }), claimSignal);
634
+ }
635
+ }
636
+ return withState(async (state, save) => {
637
+ // Another caller can commit while the sender lock is released. Recheck
638
+ // the exact request and retain any already persisted claim before writing.
639
+ const { prepared, pendingClaim, prunedPendingClaims } = await inspectOutgoing(state, save);
640
+ if (prepared) return prepared;
641
+ // A directory query has no durable outgoing message and may be retried
642
+ // with a new requestId after identity verification. Reuse only its still
643
+ // unused claim, under this lock, with the claim's original wire ID.
644
+ const wireClientMessageId = reusableDirectoryClaim(pendingClaim || {}) ? pendingClaim.clientMessageId : clientMessageId;
645
+ let rawClaim = pendingClaim?.claim || sameHostClaim;
646
+ if (!rawClaim) {
647
+ try {
648
+ // Old relays reject this optional field before claiming a prekey;
649
+ // no chat probe or plaintext fallback is used to discover support.
650
+ rawClaim = await requestJson("/api/v2/e2ee/one-time-prekeys/claim", "POST",
651
+ { ...claimBody, ...(nativeContentVersion ? { nativeContentVersion } : {}) }, new Set([200]), undefined, signal);
652
+ if (nativeContentVersion && rawClaim?.nativeContentVersion !== nativeContentVersion)
653
+ throw new HelloLynkDirectTextE2eeClientError("native_directory_unavailable");
654
+ } catch (error) {
655
+ if (!nativeContentVersion || !["request_fields_forbidden", "unexpected_fields", "e2ee_route_v2_disabled"].includes(error?.code)) throw error;
656
+ throw new HelloLynkDirectTextE2eeClientError("native_directory_unavailable", { cause: error });
657
+ }
658
+ }
659
+ const claim = validateClaimResponseV2(rawClaim, {
660
+ recipientHelloLynkId, routeKind, ...routeTarget,
661
+ senderDelegateAuthorizationVersion: owner.authorizationVersion,
662
+ }, now());
663
+ const trust = observePeerIdentity(state, {
664
+ peerHelloLynkId: claim.recipientHelloLynkId,
665
+ peerPrincipalId: claim.recipientPrincipalId,
666
+ peerDeviceId: claim.recipientDeviceId,
667
+ identity: claim.identity,
668
+ observedAt: claim.claimedAt,
669
+ }, now());
670
+ if (pendingClaim) state.pendingPeerClaims.splice(state.pendingPeerClaims.indexOf(pendingClaim), 1);
671
+ const createdAtMs = now();
672
+ const expiresAtMs = Math.min(createdAtMs + DIRECT_TEXT_E2EE_MAX_TTL_MS, Date.parse(claim.expiresAt));
673
+ if (expiresAtMs <= createdAtMs) throw new HelloLynkDirectTextE2eeClientError("e2ee_claim_expired");
674
+ const envelope = sealDirectTextEnvelopeV2({
675
+ protectedMetadata: {
676
+ clientMessageId: wireClientMessageId, routeKind,
677
+ senderPrincipalId: owner.principalId, senderHelloLynkId: owner.hellolynkId,
678
+ senderAgentId: owner.agentId, senderDelegateId: owner.delegateId,
679
+ senderDelegateAuthorizationVersion: owner.authorizationVersion,
680
+ senderHostId: owner.hostId, senderDeviceId: owner.deviceId,
681
+ senderIdentityKeyId: state.deviceKeyBundle.keyId,
682
+ recipientPrincipalId: claim.recipientPrincipalId,
683
+ recipientHelloLynkId: claim.recipientHelloLynkId,
684
+ recipientDelegateId: claim.recipientDelegateId,
685
+ recipientDelegateAuthorizationVersion: claim.recipientDelegateAuthorizationVersion,
686
+ recipientHostId: claim.recipientHostId,
687
+ recipientRouteVersion: claim.recipientRouteVersion,
688
+ recipientDeviceId: claim.recipientDeviceId,
689
+ recipientIdentityKeyId: claim.identity.keyId,
690
+ recipientPreKeyId: claim.oneTimePreKey.preKeyId,
691
+ authorizationScope: DIRECT_TEXT_E2EE_AUTHORIZATION_SCOPE,
692
+ intentSource: input.intentSource,
693
+ messageType: "text",
694
+ createdAt: new Date(createdAtMs).toISOString(),
695
+ expiresAt: new Date(expiresAtMs).toISOString(),
696
+ ...(replyToClientMessageId ? { replyToClientMessageId } : {}),
697
+ },
698
+ recipientDeviceIdentity: claim.identity,
699
+ recipientOneTimePreKey: claim.oneTimePreKey,
700
+ senderDeviceKeyBundle: state.deviceKeyBundle,
701
+ text: sealedContent,
702
+ }, { nowMs: createdAtMs });
703
+ const record = {
704
+ e2eeVersion: 2, clientMessageId: wireClientMessageId, recipientHelloLynkId,
705
+ requestDigest: wireClientMessageId === clientMessageId ? requestDigest : digestRequestV2({
706
+ ...input, routeKind, recipientHelloLynkId, clientMessageId: wireClientMessageId, ...routeTarget,
707
+ }),
708
+ ...(nativeContentVersion ? { nativeContentVersion } : {}),
709
+ routeKind, recipientDelegateId: claim.recipientDelegateId,
710
+ recipientHostId: claim.recipientHostId, recipientDeviceId: claim.recipientDeviceId,
711
+ claimId: claim.claimId, envelope, envelopeDigest: digestDirectTextEnvelopeV2(envelope),
712
+ };
713
+ if (persistPrepared !== false) state.preparedOutgoing.push(record);
714
+ // The durable outbox owns preparation/recovery when its plaintext row
715
+ // already exists. It attaches this exact envelope before any dispatch,
716
+ // so a second encrypted crash-window copy adds two private-key writes.
717
+ // Identity/claim changes still need their own durable checkpoint.
718
+ if (persistPrepared !== false || trust.changed || pendingClaim || prunedPendingClaims) await save();
719
+ return publicPrepared(record);
720
+ }, signal);
721
+ }
722
+
723
+ async function openIncoming(input) {
724
+ if (e2eeVersion === 2) return openIncomingV2(input);
725
+ requireExactFields(input, ["event", "recipientPrincipalId", "recipientDelegateId"], "e2ee_open_client_input_invalid");
726
+ if (input.recipientPrincipalId !== owner.principalId || input.recipientDelegateId !== owner.delegateId) {
727
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_recipient_context_mismatch");
728
+ }
729
+ const event = validateIncomingEvent(input.event, owner);
730
+ return withState(async (state, save) => {
731
+ if (event.recipientIdentityKeyId !== state.deviceKeyBundle.keyId) {
732
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_recipient_identity_mismatch");
733
+ }
734
+ const preKey = state.preKeys.find((item) => (
735
+ ["registered", "used"].includes(item.state)
736
+ && item.privateKeyBundle.preKeyId === event.recipientPreKeyId
737
+ ));
738
+ if (!preKey) throw new HelloLynkDirectTextE2eeClientError("e2ee_recipient_prekey_unavailable");
739
+ if (preKey.state === "used" && (
740
+ (preKey.usedClientMessageId != null && preKey.usedClientMessageId !== event.clientMessageId)
741
+ || (preKey.usedEnvelopeDigest != null && preKey.usedEnvelopeDigest !== event.envelopeDigest)
742
+ )) throw new HelloLynkDirectTextE2eeClientError("e2ee_prekey_consumption_conflict");
743
+ const trust = observePeerIdentity(state, {
744
+ peerHelloLynkId: event.senderHelloLynkId,
745
+ peerPrincipalId: event.senderPrincipalId,
746
+ peerDeviceId: event.senderDeviceId,
747
+ identity: event.senderIdentity,
748
+ observedAt: event.relayedAt,
749
+ }, now());
750
+ const opened = openDirectTextEnvelope({
751
+ envelope: event.e2eeEnvelope,
752
+ senderDeviceIdentity: event.senderIdentity,
753
+ recipientDeviceKeyBundle: state.deviceKeyBundle,
754
+ recipientOneTimePreKey: preKey.privateKeyBundle,
755
+ expectedRecipient: {
756
+ principalId: owner.principalId,
757
+ hellolynkId: owner.hellolynkId,
758
+ deviceId: owner.deviceId,
759
+ },
760
+ }, { nowMs: now() });
761
+ if (opened.envelopeDigest !== event.envelopeDigest) {
762
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_envelope_mismatch");
763
+ }
764
+ for (const [outerField, protectedField] of OUTER_PROTECTED_BINDINGS) {
765
+ if (opened.protectedMetadata[protectedField] !== event[outerField]) {
766
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_event_binding_mismatch");
767
+ }
768
+ }
769
+ if (trust.changed) await save();
770
+ let commitStarted = false;
771
+ return {
772
+ text: opened.text,
773
+ envelopeDigest: opened.envelopeDigest,
774
+ senderIdentityKeyId: event.senderIdentityKeyId,
775
+ recipientIdentityKeyId: event.recipientIdentityKeyId,
776
+ senderPrincipalId: opened.protectedMetadata.senderPrincipalId,
777
+ senderDelegateId: opened.protectedMetadata.senderDelegateId,
778
+ intentSource: opened.protectedMetadata.intentSource,
779
+ authorizationScope: opened.protectedMetadata.authorizationScope,
780
+ async commit() {
781
+ if (commitStarted) return;
782
+ commitStarted = true;
783
+ try {
784
+ await withState(async (latest, saveLatest) => {
785
+ consumePreKey(
786
+ latest,
787
+ event.recipientPreKeyId,
788
+ event.clientMessageId,
789
+ event.envelopeDigest,
790
+ event.e2eeEnvelope.protected.expiresAt,
791
+ now(),
792
+ );
793
+ await saveLatest();
794
+ });
795
+ } catch (error) {
796
+ commitStarted = false;
797
+ throw error;
798
+ }
799
+ },
800
+ };
801
+ });
802
+ }
803
+
804
+ async function openIncomingV2(input) {
805
+ requireExactFields(input, ["event", "recipientPrincipalId", "recipientDelegateId"], "e2ee_open_client_input_invalid");
806
+ if (input.recipientPrincipalId !== owner.principalId || input.recipientDelegateId !== owner.delegateId) {
807
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_recipient_context_mismatch");
808
+ }
809
+ const event = validateIncomingEventV2(input.event, owner);
810
+ const openFromState = async (state, save) => {
811
+ if (event.recipientIdentityKeyId !== state.deviceKeyBundle.keyId) {
812
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_recipient_identity_mismatch");
813
+ }
814
+ const persistedPreKey = state.preKeys.find((item) => (
815
+ ["registered", "used"].includes(item.state)
816
+ && item.privateKeyBundle.preKeyId === event.recipientPreKeyId
817
+ ));
818
+ const localPreKey = state.sameHostPreKeys.find((item) => (
819
+ item.privateKeyBundle.preKeyId === event.recipientPreKeyId
820
+ )) || null;
821
+ const preKey = persistedPreKey || localPreKey;
822
+ if (!preKey) throw new HelloLynkDirectTextE2eeClientError("e2ee_recipient_prekey_unavailable");
823
+ if (localPreKey && (
824
+ localPreKey.clientMessageId !== event.clientMessageId
825
+ || localPreKey.senderDelegateId !== event.senderDelegateId
826
+ )) throw new HelloLynkDirectTextE2eeClientError("e2ee_prekey_consumption_conflict");
827
+ if (preKey.state === "used" && (
828
+ (preKey.usedClientMessageId != null && preKey.usedClientMessageId !== event.clientMessageId)
829
+ || (preKey.usedEnvelopeDigest != null && preKey.usedEnvelopeDigest !== event.envelopeDigest)
830
+ )) throw new HelloLynkDirectTextE2eeClientError("e2ee_prekey_consumption_conflict");
831
+ const trust = observePeerIdentity(state, {
832
+ peerHelloLynkId: event.senderHelloLynkId,
833
+ peerPrincipalId: event.senderPrincipalId,
834
+ peerDeviceId: event.senderDeviceId,
835
+ identity: event.senderIdentity,
836
+ observedAt: event.relayedAt,
837
+ }, now());
838
+ const opened = openDirectTextEnvelopeV2({
839
+ envelope: event.e2eeEnvelope,
840
+ senderDeviceIdentity: event.senderIdentity,
841
+ recipientDeviceKeyBundle: state.deviceKeyBundle,
842
+ recipientOneTimePreKey: preKey.privateKeyBundle,
843
+ expectedRecipient: {
844
+ principalId: owner.principalId,
845
+ hellolynkId: owner.hellolynkId,
846
+ delegateId: owner.delegateId,
847
+ hostId: owner.hostId,
848
+ deviceId: owner.deviceId,
849
+ },
850
+ }, { nowMs: now() });
851
+ if (
852
+ opened.protectedMetadata.recipientDelegateAuthorizationVersion !== owner.authorizationVersion
853
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_recipient_authorization_version_mismatch");
854
+ if (opened.envelopeDigest !== event.envelopeDigest) {
855
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_envelope_mismatch");
856
+ }
857
+ for (const [outerField, protectedField] of OUTER_PROTECTED_BINDINGS_V2) {
858
+ if (opened.protectedMetadata[protectedField] !== event[outerField]) {
859
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_event_binding_mismatch");
860
+ }
861
+ }
862
+ if (trust.changed) await save();
863
+ let commitStarted = false;
864
+ return {
865
+ e2eeVersion: 2,
866
+ ...decodeNativeContent(opened.text),
867
+ ...(opened.protectedMetadata.replyToClientMessageId ? { replyToClientMessageId: opened.protectedMetadata.replyToClientMessageId } : {}),
868
+ envelopeDigest: opened.envelopeDigest,
869
+ senderIdentityKeyId: event.senderIdentityKeyId,
870
+ recipientIdentityKeyId: event.recipientIdentityKeyId,
871
+ senderPrincipalId: opened.protectedMetadata.senderPrincipalId,
872
+ senderDelegateId: opened.protectedMetadata.senderDelegateId,
873
+ senderHelloLynkId: opened.protectedMetadata.senderHelloLynkId,
874
+ recipientPrincipalId: opened.protectedMetadata.recipientPrincipalId,
875
+ recipientDelegateId: opened.protectedMetadata.recipientDelegateId,
876
+ recipientHelloLynkId: opened.protectedMetadata.recipientHelloLynkId,
877
+ routeKind: opened.protectedMetadata.routeKind,
878
+ intentSource: opened.protectedMetadata.intentSource,
879
+ authorizationScope: opened.protectedMetadata.authorizationScope,
880
+ async commit() {
881
+ if (commitStarted) return;
882
+ commitStarted = true;
883
+ try {
884
+ await withState(async (latest, saveLatest) => {
885
+ const sameHostPreKey = latest.sameHostPreKeys.find((item) => (
886
+ item.privateKeyBundle.preKeyId === event.recipientPreKeyId
887
+ ));
888
+ if (sameHostPreKey) {
889
+ if (sameHostPreKey.clientMessageId !== event.clientMessageId
890
+ || (sameHostPreKey.usedEnvelopeDigest != null
891
+ && sameHostPreKey.usedEnvelopeDigest !== event.envelopeDigest)) {
892
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_prekey_consumption_conflict");
893
+ }
894
+ sameHostPreKey.state = "used";
895
+ sameHostPreKey.usedEnvelopeDigest = event.envelopeDigest;
896
+ await saveLatest();
897
+ return;
898
+ }
899
+ consumePreKey(
900
+ latest, event.recipientPreKeyId, event.clientMessageId,
901
+ event.envelopeDigest, event.e2eeEnvelope.protected.expiresAt, now(),
902
+ );
903
+ await saveLatest();
904
+ });
905
+ } catch (error) {
906
+ commitStarted = false;
907
+ throw error;
908
+ }
909
+ },
910
+ };
911
+ };
912
+ // An already verified identity can be checked and decrypted under the same
913
+ // lock and state load. Nothing is cached across operations or lock releases.
914
+ const opened = await withState((state, save) => {
915
+ const verified = state.peerIdentityPins.some((pin) => (
916
+ pin.peerHelloLynkId === event.senderHelloLynkId
917
+ && pin.peerPrincipalId === event.senderPrincipalId
918
+ && pin.peerDeviceId === event.senderDeviceId
919
+ && pin.identityKeyId === event.senderIdentity.keyId
920
+ && pin.publicKey === event.senderIdentity.publicKey
921
+ ));
922
+ return verified ? openFromState(state, save) : null;
923
+ });
924
+ if (opened) return opened;
925
+ // Reload an unseen identity under the lock before checking and pinning the
926
+ // authenticated relay identity; never retain a trust decision across locks.
927
+ return withState((state, save) => openFromState(state, save));
928
+ }
929
+
930
+ async function listPreparedOutgoing() {
931
+ // Internal reconciliation metadata only: never export an envelope, body or
932
+ // key. The caller still needs the durable outbox's cancellation evidence.
933
+ return withState(async (state) => state.preparedOutgoing.map((item) => ({
934
+ clientMessageId: item.clientMessageId,
935
+ recipientHelloLynkId: item.recipientHelloLynkId,
936
+ envelopeDigest: item.envelopeDigest,
937
+ })));
938
+ }
939
+
940
+ async function commitOutgoing(input) {
941
+ requireExactFields(
942
+ input,
943
+ ["clientMessageId", "recipientHelloLynkId", "envelopeDigest"],
944
+ "e2ee_outgoing_commit_input_invalid",
945
+ );
946
+ const clientMessageId = safeId(input.clientMessageId, "e2ee_client_message_id_invalid");
947
+ const recipientHelloLynkId = safeId(input.recipientHelloLynkId, "e2ee_recipient_hellolynk_id_invalid");
948
+ const envelopeDigest = String(input.envelopeDigest || "");
949
+ if (!SAFE_DIGEST.test(envelopeDigest)) {
950
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_outgoing_digest_invalid");
951
+ }
952
+ return withState(async (state, save) => {
953
+ const index = state.preparedOutgoing.findIndex((item) => (
954
+ item.clientMessageId === clientMessageId && item.recipientHelloLynkId === recipientHelloLynkId
955
+ ));
956
+ if (index < 0) return { committed: true, idempotent: true };
957
+ if (state.preparedOutgoing[index].envelopeDigest !== envelopeDigest) {
958
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_outgoing_commit_binding_mismatch");
959
+ }
960
+ state.preparedOutgoing.splice(index, 1);
961
+ await save();
962
+ return { committed: true, idempotent: false };
963
+ });
964
+ }
965
+
966
+ async function commitIncomingReplay(input) {
967
+ requireExactFields(input, ["event", "storedMessage"], "e2ee_replay_commit_input_invalid");
968
+ const event = e2eeVersion === 2
969
+ ? validateIncomingEventV2(input.event, owner)
970
+ : validateIncomingEvent(input.event, owner);
971
+ const stored = input.storedMessage;
972
+ if (
973
+ !stored || typeof stored !== "object" || stored.clientMessageId !== event.clientMessageId
974
+ || stored.envelopeDigest !== event.envelopeDigest
975
+ || stored.recipientIdentityKeyId !== event.recipientIdentityKeyId
976
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_replay_commit_binding_mismatch");
977
+ const protectedMetadata = event.e2eeEnvelope.protected;
978
+ const hasStoredProtectedProvenance = [
979
+ "senderPrincipalId",
980
+ "authorizationScope",
981
+ "intentSource",
982
+ ].some((field) => Object.hasOwn(stored, field));
983
+ if (hasStoredProtectedProvenance && (
984
+ stored.senderPrincipalId !== protectedMetadata.senderPrincipalId
985
+ || stored.senderDelegateId !== protectedMetadata.senderDelegateId
986
+ || stored.intentSource !== protectedMetadata.intentSource
987
+ || stored.authorizationScope !== protectedMetadata.authorizationScope
988
+ )) throw new HelloLynkDirectTextE2eeClientError("e2ee_replay_commit_binding_mismatch");
989
+ return withState(async (state, save) => {
990
+ observePeerIdentity(state, {
991
+ peerHelloLynkId: event.senderHelloLynkId,
992
+ peerPrincipalId: event.senderPrincipalId,
993
+ peerDeviceId: event.senderDeviceId,
994
+ identity: event.senderIdentity,
995
+ observedAt: event.relayedAt,
996
+ }, now());
997
+ consumePreKey(
998
+ state,
999
+ event.recipientPreKeyId,
1000
+ event.clientMessageId,
1001
+ event.envelopeDigest,
1002
+ event.e2eeEnvelope.protected.expiresAt,
1003
+ now(),
1004
+ );
1005
+ await save();
1006
+ return { committed: true, idempotent: true };
1007
+ });
1008
+ }
1009
+
1010
+ async function createSameHostClaim(input, { signal } = {}) {
1011
+ if (e2eeVersion !== 2) throw new HelloLynkDirectTextE2eeClientError("e2ee_same_host_v2_required");
1012
+ requireExactFields(input, [
1013
+ "routeKind", "recipientDelegateId", "recipientHostId", "clientMessageId",
1014
+ "senderPrincipalId", "senderDelegateId", "senderDelegateAuthorizationVersion",
1015
+ ], "e2ee_same_host_claim_input_invalid");
1016
+ if (!["owned_delegate", "instance_direct"].includes(input.routeKind)
1017
+ || input.senderPrincipalId !== owner.principalId
1018
+ || input.senderDelegateId === owner.delegateId
1019
+ || input.recipientDelegateId !== owner.delegateId
1020
+ || input.recipientHostId !== owner.hostId
1021
+ || !Number.isSafeInteger(input.senderDelegateAuthorizationVersion)
1022
+ || input.senderDelegateAuthorizationVersion < 1) {
1023
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_same_host_route_mismatch");
1024
+ }
1025
+ const clientMessageId = safeId(input.clientMessageId, "e2ee_client_message_id_invalid");
1026
+ return withState(async (state, save) => {
1027
+ const claimedAtMs = now();
1028
+ const expiresAtMs = claimedAtMs + Math.min(DIRECT_TEXT_E2EE_MAX_TTL_MS, 5 * 60 * 1000);
1029
+ state.sameHostPreKeys = state.sameHostPreKeys.filter((item) => (
1030
+ Date.parse(item.expiresAt) > claimedAtMs
1031
+ ));
1032
+ let preKey = state.sameHostPreKeys.find((item) => (
1033
+ item.clientMessageId === clientMessageId && item.senderDelegateId === input.senderDelegateId
1034
+ ));
1035
+ if (!preKey) {
1036
+ if (state.sameHostPreKeys.length >= MAX_LOCAL_PREKEYS) {
1037
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_same_host_prekey_capacity_exceeded");
1038
+ }
1039
+ preKey = {
1040
+ state: "registered",
1041
+ privateKeyBundle: createOneTimePreKey({ nowMs: claimedAtMs }),
1042
+ clientMessageId,
1043
+ senderDelegateId: input.senderDelegateId,
1044
+ expiresAt: new Date(expiresAtMs).toISOString(),
1045
+ usedEnvelopeDigest: null,
1046
+ };
1047
+ state.sameHostPreKeys.push(preKey);
1048
+ }
1049
+ await save();
1050
+ return Object.freeze({
1051
+ ok: true,
1052
+ e2eeVersion: 2,
1053
+ routeKind: input.routeKind,
1054
+ claimId: `same_host_${createHash("sha256").update(`${owner.hostId}\0${owner.delegateId}\0${clientMessageId}`).digest("hex").slice(0, 32)}`,
1055
+ senderDelegateAuthorizationVersion: input.senderDelegateAuthorizationVersion,
1056
+ recipientPrincipalId: owner.principalId,
1057
+ recipientHelloLynkId: owner.hellolynkId,
1058
+ recipientDelegateId: owner.delegateId,
1059
+ recipientDelegateAuthorizationVersion: owner.authorizationVersion,
1060
+ recipientHostId: owner.hostId,
1061
+ recipientRouteVersion: 0,
1062
+ recipientDeviceId: owner.deviceId,
1063
+ identity: publicDirectTextDeviceIdentity(state.deviceKeyBundle),
1064
+ oneTimePreKey: publicOneTimePreKey(preKey.privateKeyBundle, state.deviceKeyBundle),
1065
+ claimedAt: new Date(claimedAtMs).toISOString(),
1066
+ expiresAt: preKey.expiresAt,
1067
+ });
1068
+ }, signal);
1069
+ }
1070
+
1071
+ async function authorizePeerIdentity(input, { assertCurrentAuthority } = {}) {
1072
+ const targeted = Object.hasOwn(input ?? {}, "peerDeviceId") || Object.hasOwn(input ?? {}, "identityKeyId");
1073
+ requireExactFields(input, targeted
1074
+ ? ["peerHelloLynkId", "safetyCode", "peerDeviceId", "identityKeyId"]
1075
+ : ["peerHelloLynkId", "safetyCode"], "e2ee_peer_authorization_input_invalid");
1076
+ const peerHelloLynkId = safeId(input.peerHelloLynkId, "e2ee_peer_hellolynk_id_invalid");
1077
+ const safetyCode = normalizeDirectTextSafetyCode(input.safetyCode);
1078
+ const peerDeviceId = targeted ? safeId(input.peerDeviceId, "e2ee_peer_device_id_invalid") : null;
1079
+ const expectedKeyId = targeted ? safeId(input.identityKeyId, "e2ee_peer_identity_key_id_invalid") : null;
1080
+ return withState(async (state, save) => {
1081
+ // The native dialog selects an already observed device. Recheck its key
1082
+ // and code under the state lock before creating any authorization.
1083
+ if (targeted && !state.peerIdentityPins.some((pin) => (
1084
+ pin.peerHelloLynkId === peerHelloLynkId && pin.peerDeviceId === peerDeviceId
1085
+ && pin.identityKeyId === expectedKeyId && pin.safetyCode === safetyCode
1086
+ ))) {
1087
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_peer_identity_authorization_mismatch");
1088
+ }
1089
+ if (assertCurrentAuthority != null) await assertCurrentAuthority();
1090
+ const timestamp = new Date(now()).toISOString();
1091
+ let authorization = state.peerIdentityAuthorizations.find((item) => (
1092
+ item.peerHelloLynkId === peerHelloLynkId && item.safetyCode === safetyCode
1093
+ ));
1094
+ if (!authorization) {
1095
+ if (state.peerIdentityAuthorizations.length >= MAX_PEER_AUTHORIZATIONS) {
1096
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_peer_authorization_capacity_exceeded");
1097
+ }
1098
+ authorization = { peerHelloLynkId, safetyCode, authorizedAt: timestamp };
1099
+ state.peerIdentityAuthorizations.push(authorization);
1100
+ }
1101
+ const verified = [];
1102
+ for (const pin of state.peerIdentityPins) {
1103
+ if (pin.peerHelloLynkId !== peerHelloLynkId || pin.safetyCode !== safetyCode) continue;
1104
+ if (targeted && (pin.peerDeviceId !== peerDeviceId || pin.identityKeyId !== expectedKeyId)) continue;
1105
+ if (pin.verificationState !== "verified") {
1106
+ pin.verificationState = "verified";
1107
+ pin.verifiedAt = timestamp;
1108
+ }
1109
+ verified.push(publicPeerIdentityPin(pin));
1110
+ }
1111
+ try {
1112
+ await save();
1113
+ } catch {
1114
+ // A credential helper may replace the durable file before losing its
1115
+ // acknowledgement. No storage error may masquerade as a pre-write
1116
+ // identity mismatch; retain the established identity and do not replay.
1117
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_peer_authorization_outcome_unknown");
1118
+ }
1119
+ return Object.freeze({
1120
+ authorized: true,
1121
+ peerHelloLynkId,
1122
+ safetyCode,
1123
+ observedIdentitiesVerified: verified.length,
1124
+ identities: verified,
1125
+ });
1126
+ });
1127
+ }
1128
+
1129
+ async function listPeerIdentityVerifications() {
1130
+ return withState(async (state) => Object.freeze({
1131
+ localDevice: Object.freeze({
1132
+ hellolynkId: owner.hellolynkId,
1133
+ deviceId: owner.deviceId,
1134
+ identityKeyId: state.deviceKeyBundle.keyId,
1135
+ safetyCode: localDeviceSafetyCode,
1136
+ }),
1137
+ peers: state.peerIdentityPins.map(publicPeerIdentityPin),
1138
+ authorizations: state.peerIdentityAuthorizations.map((item) => Object.freeze({ ...item })),
1139
+ }));
1140
+ }
1141
+
1142
+ async function sealContactRequestIntro(input = {}) {
1143
+ assertPlainObject(input, "contact_request_e2ee_seal_input_invalid");
1144
+ requireExactFields(input, [
1145
+ "protectedMetadata", "recipientDeviceIdentity", "recipientOneTimePreKey", "introText", "introDigestSalt",
1146
+ ], "contact_request_e2ee_seal_input_invalid");
1147
+ const metadata = input.protectedMetadata;
1148
+ assertPlainObject(metadata, "contact_request_protected_metadata_invalid");
1149
+ return withState(async (state) => {
1150
+ if (metadata.senderPrincipalId !== owner.principalId
1151
+ || metadata.senderHelloLynkId !== owner.hellolynkId
1152
+ || metadata.senderDeviceId !== owner.deviceId
1153
+ || metadata.senderIdentityKeyId !== state.deviceKeyBundle.keyId) {
1154
+ throw new HelloLynkDirectTextE2eeClientError("contact_request_sender_owner_mismatch");
1155
+ }
1156
+ try {
1157
+ return sealContactRequestIntroEnvelope({
1158
+ ...input,
1159
+ senderDeviceKeyBundle: state.deviceKeyBundle,
1160
+ }, { nowMs: now() });
1161
+ } catch (error) {
1162
+ throw clientFailure(error, "contact_request_encryption_failed");
1163
+ }
1164
+ });
1165
+ }
1166
+
1167
+ return Object.freeze({
1168
+ mode: "required",
1169
+ version: String(e2eeVersion),
1170
+ identityKeyId,
1171
+ localDeviceSafetyCode,
1172
+ authorizePeerIdentity,
1173
+ listPeerIdentityVerifications,
1174
+ sealContactRequestIntro,
1175
+ prepareOutgoing,
1176
+ listPreparedOutgoing,
1177
+ preKeyInventoryStatus,
1178
+ commitOutgoing,
1179
+ ensurePreKeyInventory,
1180
+ replenishPreKeyInventory: ensurePreKeyInventory,
1181
+ openIncoming,
1182
+ commitIncomingReplay,
1183
+ createSameHostClaim,
1184
+ publicDeviceIdentity: () => withState(async (state) => publicDirectTextDeviceIdentity(state.deviceKeyBundle)),
1185
+ });
1186
+ }
1187
+
1188
+ function createInitialState(profileId, owner, preKeyCount, nowMs) {
1189
+ const deviceKeyBundle = createDirectTextDeviceKeyBundle({ nowMs });
1190
+ return {
1191
+ schemaVersion: STATE_SCHEMA_VERSION,
1192
+ profileId,
1193
+ owner: { ...owner },
1194
+ deviceKeyBundle,
1195
+ preKeys: Array.from({ length: preKeyCount }, (_, index) => (
1196
+ createLocalPreKey(nowMs + index)
1197
+ )),
1198
+ peerIdentityPins: [],
1199
+ peerIdentityAuthorizations: [],
1200
+ pendingPeerClaims: [],
1201
+ preparedOutgoing: [],
1202
+ consumedPreKeys: [],
1203
+ sameHostPreKeys: [],
1204
+ };
1205
+ }
1206
+
1207
+ function normalizeState(state, profileId, owner) {
1208
+ if (state?.schemaVersion === 6) {
1209
+ requireExactFields(state, [
1210
+ "schemaVersion", "profileId", "owner", "deviceKeyBundle", "preKeys",
1211
+ "peerIdentityPins", "peerIdentityAuthorizations", "pendingPeerClaims",
1212
+ "preparedOutgoing", "consumedPreKeys",
1213
+ ], "e2ee_private_state_invalid");
1214
+ const migrated = structuredClone(state);
1215
+ migrated.schemaVersion = STATE_SCHEMA_VERSION;
1216
+ migrated.sameHostPreKeys = [];
1217
+ return { state: validateState(migrated, profileId, owner), migrated: true };
1218
+ }
1219
+ return { state: validateState(state, profileId, owner), migrated: false };
1220
+ }
1221
+
1222
+ function validateState(state, profileId, owner) {
1223
+ requireExactFields(state, [
1224
+ "schemaVersion", "profileId", "owner", "deviceKeyBundle", "preKeys",
1225
+ "peerIdentityPins", "peerIdentityAuthorizations", "pendingPeerClaims",
1226
+ "preparedOutgoing", "consumedPreKeys", "sameHostPreKeys",
1227
+ ], "e2ee_private_state_invalid");
1228
+ if (state.schemaVersion !== STATE_SCHEMA_VERSION || state.profileId !== profileId) {
1229
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1230
+ }
1231
+ requireExactFields(state.owner, Object.hasOwn(owner, "hostId")
1232
+ ? ["principalId", "hellolynkId", "agentId", "delegateId", "deviceId", "hostId", "authorizationVersion"]
1233
+ : ["principalId", "hellolynkId", "agentId", "delegateId", "deviceId"], "e2ee_private_state_invalid");
1234
+ if (JSON.stringify(state.owner) !== JSON.stringify(owner)) {
1235
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_owner_mismatch");
1236
+ }
1237
+ publicDirectTextDeviceIdentity(state.deviceKeyBundle);
1238
+ if (!Array.isArray(state.preKeys) || state.preKeys.length > MAX_LOCAL_PREKEYS) {
1239
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1240
+ }
1241
+ for (const item of state.preKeys) {
1242
+ requireExactFields(item, [
1243
+ "state", "privateKeyBundle", "registeredAt", "claimExpiresAt",
1244
+ "usedClientMessageId", "usedEnvelopeDigest",
1245
+ ], "e2ee_private_state_invalid");
1246
+ if (!["pending_registration", "registered", "used"].includes(item.state)) {
1247
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1248
+ }
1249
+ publicOneTimePreKey(item.privateKeyBundle, state.deviceKeyBundle);
1250
+ if (item.registeredAt != null) iso(item.registeredAt, "e2ee_private_state_invalid");
1251
+ if (item.claimExpiresAt != null) iso(item.claimExpiresAt, "e2ee_private_state_invalid");
1252
+ if (item.state === "pending_registration" && (
1253
+ item.registeredAt != null || item.claimExpiresAt != null
1254
+ || item.usedClientMessageId != null || item.usedEnvelopeDigest != null
1255
+ )) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1256
+ if (item.state === "used") {
1257
+ if (item.claimExpiresAt == null) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1258
+ if ((item.usedClientMessageId == null) !== (item.usedEnvelopeDigest == null)) {
1259
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1260
+ }
1261
+ if (item.usedClientMessageId != null) {
1262
+ safeId(item.usedClientMessageId, "e2ee_private_state_invalid");
1263
+ if (!SAFE_DIGEST.test(String(item.usedEnvelopeDigest || ""))) {
1264
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1265
+ }
1266
+ }
1267
+ } else if (item.usedClientMessageId != null || item.usedEnvelopeDigest != null) {
1268
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1269
+ }
1270
+ }
1271
+ if (!Array.isArray(state.sameHostPreKeys) || state.sameHostPreKeys.length > MAX_LOCAL_PREKEYS) {
1272
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1273
+ }
1274
+ const sameHostPreKeyIds = new Set();
1275
+ const sameHostClaimKeys = new Set();
1276
+ for (const item of state.sameHostPreKeys) {
1277
+ requireExactFields(item, [
1278
+ "state", "privateKeyBundle", "clientMessageId", "senderDelegateId", "expiresAt", "usedEnvelopeDigest",
1279
+ ], "e2ee_private_state_invalid");
1280
+ if (!["registered", "used"].includes(item.state)) {
1281
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1282
+ }
1283
+ publicOneTimePreKey(item.privateKeyBundle, state.deviceKeyBundle);
1284
+ safeId(item.clientMessageId, "e2ee_private_state_invalid");
1285
+ safeId(item.senderDelegateId, "e2ee_private_state_invalid");
1286
+ const sameHostExpiry = iso(item.expiresAt, "e2ee_private_state_invalid");
1287
+ if (Date.parse(sameHostExpiry) > Date.now() + DIRECT_TEXT_E2EE_MAX_TTL_MS) {
1288
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1289
+ }
1290
+ const preKeyId = item.privateKeyBundle.preKeyId;
1291
+ const claimKey = `${item.senderDelegateId}\0${item.clientMessageId}`;
1292
+ if (sameHostPreKeyIds.has(preKeyId) || sameHostClaimKeys.has(claimKey)) {
1293
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1294
+ }
1295
+ sameHostPreKeyIds.add(preKeyId);
1296
+ sameHostClaimKeys.add(claimKey);
1297
+ if (item.state === "used") {
1298
+ if (!SAFE_DIGEST.test(String(item.usedEnvelopeDigest || ""))) {
1299
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1300
+ }
1301
+ } else if (item.usedEnvelopeDigest != null) {
1302
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1303
+ }
1304
+ }
1305
+ if (!Array.isArray(state.peerIdentityPins) || state.peerIdentityPins.length > MAX_PEER_PINS) {
1306
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1307
+ }
1308
+ for (const pin of state.peerIdentityPins) {
1309
+ requireExactFields(pin, [
1310
+ "peerHelloLynkId", "peerPrincipalId", "peerDeviceId", "identityKeyId", "publicKey",
1311
+ "firstSeenAt", "safetyCode", "verificationState", "verifiedAt",
1312
+ ], "e2ee_private_state_invalid");
1313
+ safeId(pin.peerHelloLynkId, "e2ee_private_state_invalid");
1314
+ safeId(pin.peerPrincipalId, "e2ee_private_state_invalid");
1315
+ safeId(pin.peerDeviceId, "e2ee_private_state_invalid");
1316
+ safeId(pin.identityKeyId, "e2ee_private_state_invalid");
1317
+ iso(pin.firstSeenAt, "e2ee_private_state_invalid");
1318
+ if (pin.safetyCode !== directTextDeviceSafetyCode({
1319
+ hellolynkId: pin.peerHelloLynkId,
1320
+ deviceId: pin.peerDeviceId,
1321
+ identityKeyId: pin.identityKeyId,
1322
+ publicKey: pin.publicKey,
1323
+ })) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1324
+ if (!["pending", "verified"].includes(pin.verificationState)) {
1325
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1326
+ }
1327
+ if (pin.verificationState === "verified") iso(pin.verifiedAt, "e2ee_private_state_invalid");
1328
+ else if (pin.verifiedAt != null) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1329
+ }
1330
+ if (
1331
+ !Array.isArray(state.peerIdentityAuthorizations)
1332
+ || state.peerIdentityAuthorizations.length > MAX_PEER_AUTHORIZATIONS
1333
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1334
+ for (const authorization of state.peerIdentityAuthorizations) {
1335
+ requireExactFields(authorization, ["peerHelloLynkId", "safetyCode", "authorizedAt"], "e2ee_private_state_invalid");
1336
+ safeId(authorization.peerHelloLynkId, "e2ee_private_state_invalid");
1337
+ normalizeDirectTextSafetyCode(authorization.safetyCode);
1338
+ iso(authorization.authorizedAt, "e2ee_private_state_invalid");
1339
+ }
1340
+ if (!Array.isArray(state.pendingPeerClaims) || state.pendingPeerClaims.length > MAX_PENDING_PEER_CLAIMS) {
1341
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1342
+ }
1343
+ for (const pending of state.pendingPeerClaims) {
1344
+ requireExactFields(pending, pending.e2eeVersion === 2
1345
+ ? [
1346
+ "e2eeVersion", "recipientHelloLynkId", "clientMessageId", "requestDigest", "routeKind",
1347
+ "recipientDelegateId", "recipientHostId", "recipientDeviceId", "claim", "storedAt",
1348
+ ]
1349
+ : ["e2eeVersion", "recipientHelloLynkId", "clientMessageId", "claim", "storedAt"],
1350
+ "e2ee_private_state_invalid");
1351
+ const recipientHelloLynkId = safeId(pending.recipientHelloLynkId, "e2ee_private_state_invalid");
1352
+ if (![1, 2].includes(pending.e2eeVersion)) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1353
+ if (pending.e2eeVersion === 2 && !SAFE_DIGEST.test(String(pending.requestDigest || ""))) {
1354
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1355
+ }
1356
+ safeId(pending.clientMessageId, "e2ee_private_state_invalid");
1357
+ iso(pending.storedAt, "e2ee_private_state_invalid");
1358
+ const claim = validateStoredClaim(
1359
+ pending.claim,
1360
+ recipientHelloLynkId,
1361
+ pending.e2eeVersion === 2 ? owner.authorizationVersion : null,
1362
+ );
1363
+ if (pending.e2eeVersion === 2 && (
1364
+ pending.routeKind !== claim.routeKind
1365
+ || pending.recipientDelegateId !== claim.recipientDelegateId
1366
+ || pending.recipientHostId !== claim.recipientHostId
1367
+ || pending.recipientDeviceId !== claim.recipientDeviceId
1368
+ )) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1369
+ }
1370
+ if (!Array.isArray(state.preparedOutgoing) || state.preparedOutgoing.length > MAX_PREPARED_OUTGOING) {
1371
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1372
+ }
1373
+ const preparedKeys = new Set();
1374
+ for (const record of state.preparedOutgoing) {
1375
+ const version = record?.e2eeVersion;
1376
+ requireExactFields(record, version === 2 ? [
1377
+ "e2eeVersion", "clientMessageId", "recipientHelloLynkId", "requestDigest",
1378
+ "routeKind", "recipientDelegateId", "recipientHostId", "recipientDeviceId",
1379
+ "claimId", "envelope", "envelopeDigest",
1380
+ ...(Object.hasOwn(record, "nativeContentVersion") ? ["nativeContentVersion"] : []),
1381
+ ] : [
1382
+ "e2eeVersion", "clientMessageId", "recipientHelloLynkId", "requestDigest",
1383
+ "claimId", "envelope", "envelopeDigest",
1384
+ ], "e2ee_private_state_invalid");
1385
+ if (![1, 2].includes(version)) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1386
+ if (Object.hasOwn(record, "nativeContentVersion") && record.nativeContentVersion !== 1)
1387
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1388
+ const clientMessageId = safeId(record.clientMessageId, "e2ee_private_state_invalid");
1389
+ const recipientHelloLynkId = safeId(record.recipientHelloLynkId, "e2ee_private_state_invalid");
1390
+ safeId(record.claimId, "e2ee_private_state_invalid");
1391
+ if (!SAFE_DIGEST.test(String(record.requestDigest || ""))) {
1392
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1393
+ }
1394
+ let envelopeDigest;
1395
+ try {
1396
+ envelopeDigest = version === 2
1397
+ ? digestDirectTextEnvelopeV2(record.envelope)
1398
+ : digestDirectTextEnvelope(record.envelope);
1399
+ } catch (error) {
1400
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid", { cause: error });
1401
+ }
1402
+ if (record.envelopeDigest !== envelopeDigest) {
1403
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1404
+ }
1405
+ const protectedMetadata = record.envelope?.protected;
1406
+ if (
1407
+ protectedMetadata?.clientMessageId !== clientMessageId
1408
+ || protectedMetadata?.recipientHelloLynkId !== recipientHelloLynkId
1409
+ || protectedMetadata?.senderPrincipalId !== owner.principalId
1410
+ || protectedMetadata?.senderHelloLynkId !== owner.hellolynkId
1411
+ || protectedMetadata?.senderAgentId !== owner.agentId
1412
+ || protectedMetadata?.senderDelegateId !== owner.delegateId
1413
+ || protectedMetadata?.senderDeviceId !== owner.deviceId
1414
+ || protectedMetadata?.senderIdentityKeyId !== state.deviceKeyBundle.keyId
1415
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1416
+ if (version === 2) {
1417
+ if (
1418
+ !Object.hasOwn(owner, "hostId")
1419
+ || !["principal_inbox", "owned_delegate", "instance_direct"].includes(record.routeKind)
1420
+ || record.routeKind !== protectedMetadata.routeKind
1421
+ || safeId(record.recipientDelegateId, "e2ee_private_state_invalid") !== protectedMetadata.recipientDelegateId
1422
+ || safeId(record.recipientHostId, "e2ee_private_state_invalid") !== protectedMetadata.recipientHostId
1423
+ || safeId(record.recipientDeviceId, "e2ee_private_state_invalid") !== protectedMetadata.recipientDeviceId
1424
+ || protectedMetadata.senderHostId !== owner.hostId
1425
+ || protectedMetadata.senderDelegateAuthorizationVersion !== owner.authorizationVersion
1426
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1427
+ }
1428
+ const key = clientMessageId;
1429
+ if (preparedKeys.has(key)) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1430
+ preparedKeys.add(key);
1431
+ }
1432
+ if (!Array.isArray(state.consumedPreKeys) || state.consumedPreKeys.length > MAX_CONSUMED_RECORDS) {
1433
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1434
+ }
1435
+ const consumedIds = new Set();
1436
+ for (const record of state.consumedPreKeys) {
1437
+ requireExactFields(
1438
+ record,
1439
+ ["preKeyId", "clientMessageId", "envelopeDigest", "consumedAt"],
1440
+ "e2ee_private_state_invalid",
1441
+ );
1442
+ const preKeyId = safeId(record.preKeyId, "e2ee_private_state_invalid");
1443
+ const clientMessageId = safeId(record.clientMessageId, "e2ee_private_state_invalid");
1444
+ if (!SAFE_DIGEST.test(String(record.envelopeDigest || ""))) {
1445
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1446
+ }
1447
+ iso(record.consumedAt, "e2ee_private_state_invalid");
1448
+ if (consumedIds.has(preKeyId)) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1449
+ consumedIds.add(preKeyId);
1450
+ const preKey = state.preKeys.find((item) => item.privateKeyBundle.preKeyId === preKeyId);
1451
+ if (preKey && (
1452
+ preKey.state !== "used"
1453
+ || preKey.usedClientMessageId !== clientMessageId
1454
+ || preKey.usedEnvelopeDigest !== record.envelopeDigest
1455
+ )) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1456
+ }
1457
+ for (const preKey of state.preKeys) {
1458
+ if (preKey.state !== "used" || preKey.usedClientMessageId == null) continue;
1459
+ if (!state.consumedPreKeys.some((record) => (
1460
+ record.preKeyId === preKey.privateKeyBundle.preKeyId
1461
+ && record.clientMessageId === preKey.usedClientMessageId
1462
+ && record.envelopeDigest === preKey.usedEnvelopeDigest
1463
+ ))) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1464
+ }
1465
+ return state;
1466
+ }
1467
+
1468
+ function e2eeOwnerBaseIdentity(owner) {
1469
+ return JSON.stringify({
1470
+ principalId: owner?.principalId,
1471
+ hellolynkId: owner?.hellolynkId,
1472
+ agentId: owner?.agentId,
1473
+ delegateId: owner?.delegateId,
1474
+ deviceId: owner?.deviceId,
1475
+ });
1476
+ }
1477
+
1478
+ function observePeerIdentity(state, observation, nowMs = Date.now()) {
1479
+ if (!verifyDeviceIdentityRegistration(observation.identity)) {
1480
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_peer_identity_signature_invalid");
1481
+ }
1482
+ let pin = state.peerIdentityPins.find((candidate) => (
1483
+ candidate.peerHelloLynkId === observation.peerHelloLynkId && candidate.peerDeviceId === observation.peerDeviceId
1484
+ ));
1485
+ let changed = false;
1486
+ if (pin) {
1487
+ if (
1488
+ pin.peerPrincipalId !== observation.peerPrincipalId
1489
+ || pin.identityKeyId !== observation.identity.keyId
1490
+ || pin.publicKey !== observation.identity.publicKey
1491
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_peer_identity_pin_mismatch");
1492
+ } else {
1493
+ if (state.peerIdentityPins.length >= MAX_PEER_PINS) {
1494
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_peer_identity_pin_capacity_exceeded");
1495
+ }
1496
+ pin = {
1497
+ peerHelloLynkId: observation.peerHelloLynkId,
1498
+ peerPrincipalId: observation.peerPrincipalId,
1499
+ peerDeviceId: observation.peerDeviceId,
1500
+ identityKeyId: observation.identity.keyId,
1501
+ publicKey: observation.identity.publicKey,
1502
+ firstSeenAt: iso(observation.observedAt, "e2ee_peer_identity_observed_at_invalid"),
1503
+ safetyCode: directTextDeviceSafetyCode({
1504
+ hellolynkId: observation.peerHelloLynkId,
1505
+ deviceId: observation.peerDeviceId,
1506
+ identityKeyId: observation.identity.keyId,
1507
+ publicKey: observation.identity.publicKey,
1508
+ }),
1509
+ verificationState: "pending",
1510
+ verifiedAt: null,
1511
+ };
1512
+ state.peerIdentityPins.push(pin);
1513
+ changed = true;
1514
+ }
1515
+ // Historical manual verification fields remain unchanged. The authenticated
1516
+ // directory identity, signature and exact pin binding decide whether it is usable.
1517
+ return { changed };
1518
+ }
1519
+
1520
+ function publicPeerIdentityPin(pin) {
1521
+ return Object.freeze({
1522
+ peerHelloLynkId: pin.peerHelloLynkId,
1523
+ peerPrincipalId: pin.peerPrincipalId,
1524
+ peerDeviceId: pin.peerDeviceId,
1525
+ identityKeyId: pin.identityKeyId,
1526
+ safetyCode: pin.safetyCode,
1527
+ verificationState: pin.verificationState,
1528
+ firstSeenAt: pin.firstSeenAt,
1529
+ verifiedAt: pin.verifiedAt,
1530
+ });
1531
+ }
1532
+
1533
+ function requirePreparedPeerTrust(state, record) {
1534
+ const protectedMetadata = record?.envelope?.protected;
1535
+ const pin = state.peerIdentityPins.find((item) => (
1536
+ item.peerHelloLynkId === record.recipientHelloLynkId
1537
+ && item.peerDeviceId === protectedMetadata?.recipientDeviceId
1538
+ && item.identityKeyId === protectedMetadata?.recipientIdentityKeyId
1539
+ ));
1540
+ if (!pin) throw new HelloLynkDirectTextE2eeClientError("e2ee_prepared_peer_identity_pin_missing");
1541
+ }
1542
+
1543
+ function pruneExpiredPendingPeerClaims(state, nowMs) {
1544
+ const retained = state.pendingPeerClaims.filter((item) => Date.parse(item.claim.expiresAt) > nowMs);
1545
+ if (retained.length === state.pendingPeerClaims.length) return false;
1546
+ state.pendingPeerClaims = retained;
1547
+ return true;
1548
+ }
1549
+
1550
+ function validateStoredClaim(value, recipientHelloLynkId, senderDelegateAuthorizationVersion = null) {
1551
+ const claimedAt = Date.parse(value?.claimedAt || "");
1552
+ if (!Number.isFinite(claimedAt)) throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid");
1553
+ try {
1554
+ return value?.e2eeVersion === 2
1555
+ ? validateClaimResponseV2(value, {
1556
+ recipientHelloLynkId,
1557
+ routeKind: value.routeKind,
1558
+ recipientDelegateId: value.recipientDelegateId,
1559
+ recipientHostId: value.recipientHostId,
1560
+ recipientDeviceId: value.recipientDeviceId,
1561
+ senderDelegateAuthorizationVersion,
1562
+ }, claimedAt - 1)
1563
+ : validateClaimResponse(value, recipientHelloLynkId, claimedAt - 1);
1564
+ } catch (error) {
1565
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_private_state_invalid", { cause: error });
1566
+ }
1567
+ }
1568
+
1569
+ function validateClaimResponse(value, recipientHelloLynkId, nowMs) {
1570
+ requireExactFields(value, [
1571
+ "ok", "claimId", "recipientPrincipalId", "recipientHelloLynkId", "recipientDeviceId",
1572
+ "identity", "oneTimePreKey", "claimedAt", "expiresAt",
1573
+ ], "e2ee_claim_response_invalid");
1574
+ if (value.ok !== true || value.recipientHelloLynkId !== recipientHelloLynkId) {
1575
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_claim_response_mismatch");
1576
+ }
1577
+ safeId(value.claimId, "e2ee_claim_response_invalid");
1578
+ safeId(value.recipientPrincipalId, "e2ee_claim_response_invalid");
1579
+ safeId(value.recipientDeviceId, "e2ee_claim_response_invalid");
1580
+ if (!verifyDeviceIdentityRegistration(value.identity)) {
1581
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_recipient_identity_signature_invalid");
1582
+ }
1583
+ if (!verifyOneTimePreKeyRegistration({ identity: value.identity, oneTimePreKey: value.oneTimePreKey })) {
1584
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_recipient_prekey_signature_invalid");
1585
+ }
1586
+ const claimedAt = iso(value.claimedAt, "e2ee_claim_response_invalid");
1587
+ const expiresAt = iso(value.expiresAt, "e2ee_claim_response_invalid");
1588
+ if (Date.parse(expiresAt) <= nowMs || Date.parse(expiresAt) - Date.parse(claimedAt) > DIRECT_TEXT_E2EE_MAX_TTL_MS) {
1589
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_claim_expired");
1590
+ }
1591
+ return value;
1592
+ }
1593
+
1594
+ function validateClaimResponseV2(value, expected, nowMs) {
1595
+ requireExactFields(value, [
1596
+ "ok", "e2eeVersion", "routeKind", "claimId", "senderDelegateAuthorizationVersion",
1597
+ "recipientPrincipalId", "recipientHelloLynkId", "recipientDelegateId",
1598
+ "recipientDelegateAuthorizationVersion", "recipientHostId", "recipientRouteVersion",
1599
+ "recipientDeviceId", "identity", "oneTimePreKey", "claimedAt", "expiresAt",
1600
+ ...(Object.hasOwn(value || {}, "nativeContentVersion") ? ["nativeContentVersion"] : []),
1601
+ ], "e2ee_claim_response_invalid");
1602
+ if (Object.hasOwn(value, "nativeContentVersion") && value.nativeContentVersion !== 1)
1603
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_claim_response_invalid");
1604
+ if (
1605
+ value.ok !== true || value.e2eeVersion !== 2 || value.routeKind !== expected.routeKind
1606
+ || value.senderDelegateAuthorizationVersion !== expected.senderDelegateAuthorizationVersion
1607
+ || value.recipientHelloLynkId !== expected.recipientHelloLynkId
1608
+ || (expected.routeKind === "owned_delegate" && (
1609
+ value.recipientDelegateId !== expected.recipientDelegateId
1610
+ || value.recipientHostId !== expected.recipientHostId
1611
+ || (expected.recipientDeviceId != null && value.recipientDeviceId !== expected.recipientDeviceId)
1612
+ ))
1613
+ || (expected.routeKind === "instance_direct" && value.recipientDelegateId !== expected.recipientDelegateId)
1614
+ || !Number.isSafeInteger(value.senderDelegateAuthorizationVersion)
1615
+ || value.senderDelegateAuthorizationVersion < 1
1616
+ || !Number.isSafeInteger(value.recipientDelegateAuthorizationVersion)
1617
+ || value.recipientDelegateAuthorizationVersion < 1
1618
+ || !Number.isSafeInteger(value.recipientRouteVersion)
1619
+ || (value.routeKind === "principal_inbox" && value.recipientRouteVersion < 1)
1620
+ || (value.routeKind === "owned_delegate" && value.recipientRouteVersion !== 0)
1621
+ || (value.routeKind === "instance_direct" && value.recipientRouteVersion !== 0)
1622
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_claim_response_mismatch");
1623
+ for (const field of [
1624
+ "claimId", "recipientPrincipalId", "recipientDelegateId", "recipientHostId", "recipientDeviceId",
1625
+ ]) safeId(value[field], "e2ee_claim_response_invalid");
1626
+ if (!verifyDeviceIdentityRegistration(value.identity)) {
1627
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_recipient_identity_signature_invalid");
1628
+ }
1629
+ if (!verifyOneTimePreKeyRegistration({ identity: value.identity, oneTimePreKey: value.oneTimePreKey })) {
1630
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_recipient_prekey_signature_invalid");
1631
+ }
1632
+ const claimedAt = iso(value.claimedAt, "e2ee_claim_response_invalid");
1633
+ const expiresAt = iso(value.expiresAt, "e2ee_claim_response_invalid");
1634
+ if (Date.parse(expiresAt) <= nowMs || Date.parse(expiresAt) - Date.parse(claimedAt) > DIRECT_TEXT_E2EE_MAX_TTL_MS) {
1635
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_claim_expired");
1636
+ }
1637
+ return value;
1638
+ }
1639
+
1640
+ function validateInventoryResponse(value, nowMs) {
1641
+ requireExactFields(value, ["ok", "oneTimePreKeys"], "e2ee_inventory_response_invalid");
1642
+ if (value.ok !== true || !Array.isArray(value.oneTimePreKeys) || value.oneTimePreKeys.length > 1000) {
1643
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_inventory_response_invalid");
1644
+ }
1645
+ const seen = new Set();
1646
+ const inventory = value.oneTimePreKeys.map((item) => {
1647
+ requireExactFields(item, ["preKeyId", "state", "claimExpiresAt"], "e2ee_inventory_response_invalid");
1648
+ const preKeyId = safeId(item.preKeyId, "e2ee_inventory_response_invalid");
1649
+ if (seen.has(preKeyId) || !["available", "claimed", "used"].includes(item.state)) {
1650
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_inventory_response_invalid");
1651
+ }
1652
+ seen.add(preKeyId);
1653
+ if (item.state === "available") {
1654
+ if (item.claimExpiresAt != null) throw new HelloLynkDirectTextE2eeClientError("e2ee_inventory_response_invalid");
1655
+ return { preKeyId, state: item.state, claimExpiresAt: null };
1656
+ }
1657
+ const claimExpiresAt = iso(item.claimExpiresAt, "e2ee_inventory_response_invalid");
1658
+ if (Date.parse(claimExpiresAt) <= nowMs) {
1659
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_inventory_response_invalid");
1660
+ }
1661
+ return { preKeyId, state: item.state, claimExpiresAt };
1662
+ });
1663
+ return inventory;
1664
+ }
1665
+
1666
+ function reconcilePreKeyInventory(state, inventory, targetAvailable, nowMs) {
1667
+ const byId = new Map(inventory.map((item) => [item.preKeyId, item]));
1668
+ const localById = new Map(state.preKeys.map((item) => [item.privateKeyBundle.preKeyId, item]));
1669
+ for (const item of inventory) {
1670
+ if (localById.has(item.preKeyId)) continue;
1671
+ const legacyConsumed = item.state === "used" && state.consumedPreKeys.some((record) => (
1672
+ record.preKeyId === item.preKeyId
1673
+ ));
1674
+ if (!legacyConsumed) throw new HelloLynkDirectTextE2eeClientError("e2ee_inventory_private_key_missing");
1675
+ }
1676
+
1677
+ let changed = false;
1678
+ const retained = [];
1679
+ for (const local of state.preKeys) {
1680
+ if (local.state === "pending_registration") {
1681
+ retained.push(local);
1682
+ continue;
1683
+ }
1684
+ const server = byId.get(local.privateKeyBundle.preKeyId);
1685
+ if (!server) {
1686
+ if (local.state === "used" && Date.parse(local.claimExpiresAt || "") > nowMs) {
1687
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_inventory_used_record_missing");
1688
+ }
1689
+ changed = true;
1690
+ continue;
1691
+ }
1692
+ if (local.state === "used" && server.state !== "used") {
1693
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_inventory_used_state_regressed");
1694
+ }
1695
+ if (server.state === "used") {
1696
+ if (local.state !== "used" || local.claimExpiresAt !== server.claimExpiresAt) changed = true;
1697
+ local.state = "used";
1698
+ local.claimExpiresAt = server.claimExpiresAt;
1699
+ } else {
1700
+ if (local.state !== "registered" || local.claimExpiresAt !== server.claimExpiresAt) changed = true;
1701
+ local.state = "registered";
1702
+ local.claimExpiresAt = server.claimExpiresAt;
1703
+ local.usedClientMessageId = null;
1704
+ local.usedEnvelopeDigest = null;
1705
+ }
1706
+ retained.push(local);
1707
+ }
1708
+ if (retained.length !== state.preKeys.length) state.preKeys = retained;
1709
+
1710
+ const retainedUsed = state.preKeys.filter((item) => item.state === "used").length;
1711
+ const available = inventory.filter((item) => item.state === "available").length;
1712
+ const generated = Math.max(0, targetAvailable - available);
1713
+ if (state.preKeys.length + generated > MAX_LOCAL_PREKEYS) {
1714
+ const expiries = state.preKeys.filter((item) => item.state === "used")
1715
+ .map((item) => Date.parse(item.claimExpiresAt)).filter((value) => value > nowMs);
1716
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_local_prekey_capacity_exceeded", {
1717
+ retryable: true,
1718
+ retryAt: expiries.length ? new Date(Math.min(...expiries)).toISOString() : null,
1719
+ });
1720
+ }
1721
+ for (let index = 0; index < generated; index += 1) {
1722
+ state.preKeys.push(createLocalPreKey(nowMs + index + 1));
1723
+ }
1724
+ if (generated > 0) changed = true;
1725
+ return { changed, available, generated, retainedUsed };
1726
+ }
1727
+
1728
+ function validateIncomingEvent(event, owner) {
1729
+ if (!event || typeof event !== "object" || Array.isArray(event)) {
1730
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_event_invalid");
1731
+ }
1732
+ if (Object.hasOwn(event, "authorizationScope") || Object.hasOwn(event, "intentSource")) {
1733
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_event_binding_mismatch");
1734
+ }
1735
+ const requiredIds = OUTER_PROTECTED_BINDINGS
1736
+ .map(([outerField]) => outerField)
1737
+ .filter((field) => field !== "messageType");
1738
+ for (const field of requiredIds) safeId(event[field], "e2ee_incoming_event_invalid");
1739
+ if (
1740
+ event.messageType !== "text" || event.recipientPrincipalId !== owner.principalId
1741
+ || event.recipientHelloLynkId !== owner.hellolynkId || event.recipientDeviceId !== owner.deviceId
1742
+ || event.senderIdentity?.keyId !== event.senderIdentityKeyId
1743
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_event_binding_mismatch");
1744
+ if (!verifyDeviceIdentityRegistration(event.senderIdentity)) {
1745
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_sender_identity_signature_invalid");
1746
+ }
1747
+ const envelopeDigest = digestDirectTextEnvelope(event.e2eeEnvelope);
1748
+ if (!SAFE_DIGEST.test(String(event.envelopeDigest || "")) || envelopeDigest !== event.envelopeDigest) {
1749
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_digest_mismatch");
1750
+ }
1751
+ for (const [outerField, protectedField] of OUTER_PROTECTED_BINDINGS) {
1752
+ if (event.e2eeEnvelope.protected[protectedField] !== event[outerField]) {
1753
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_event_binding_mismatch");
1754
+ }
1755
+ }
1756
+ return event;
1757
+ }
1758
+
1759
+ function validateIncomingEventV2(event, owner) {
1760
+ if (!event || typeof event !== "object" || Array.isArray(event) || event.schemaVersion !== 2) {
1761
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_event_invalid");
1762
+ }
1763
+ if (Object.hasOwn(event, "authorizationScope") || Object.hasOwn(event, "intentSource")) {
1764
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_event_binding_mismatch");
1765
+ }
1766
+ for (const [outerField] of OUTER_PROTECTED_BINDINGS_V2) {
1767
+ if ([
1768
+ "senderDelegateAuthorizationVersion", "recipientDelegateAuthorizationVersion", "recipientRouteVersion",
1769
+ ].includes(outerField)) continue;
1770
+ if (outerField !== "messageType" && outerField !== "routeKind") {
1771
+ safeId(event[outerField], "e2ee_incoming_event_invalid");
1772
+ }
1773
+ }
1774
+ if (
1775
+ !["principal_inbox", "owned_delegate", "instance_direct"].includes(event.routeKind)
1776
+ || event.messageType !== "text" || event.recipientPrincipalId !== owner.principalId
1777
+ || event.recipientHelloLynkId !== owner.hellolynkId || event.recipientDelegateId !== owner.delegateId
1778
+ || event.recipientHostId !== owner.hostId || event.recipientDeviceId !== owner.deviceId
1779
+ || event.recipientDelegateAuthorizationVersion !== owner.authorizationVersion
1780
+ || !Number.isSafeInteger(event.recipientRouteVersion)
1781
+ || event.senderIdentity?.keyId !== event.senderIdentityKeyId
1782
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_event_binding_mismatch");
1783
+ if (!verifyDeviceIdentityRegistration(event.senderIdentity)) {
1784
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_sender_identity_signature_invalid");
1785
+ }
1786
+ const envelopeDigest = digestDirectTextEnvelopeV2(event.e2eeEnvelope);
1787
+ if (!SAFE_DIGEST.test(String(event.envelopeDigest || "")) || envelopeDigest !== event.envelopeDigest) {
1788
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_digest_mismatch");
1789
+ }
1790
+ for (const [outerField, protectedField] of OUTER_PROTECTED_BINDINGS_V2) {
1791
+ if (event.e2eeEnvelope.protected[protectedField] !== event[outerField]) {
1792
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_incoming_event_binding_mismatch");
1793
+ }
1794
+ }
1795
+ return event;
1796
+ }
1797
+
1798
+ function consumePreKey(state, preKeyId, clientMessageId, envelopeDigest, claimExpiresAt, nowMs) {
1799
+ const consumed = state.consumedPreKeys.find((item) => item.preKeyId === preKeyId);
1800
+ const index = state.preKeys.findIndex((item) => item.privateKeyBundle.preKeyId === preKeyId);
1801
+ if (consumed && (consumed.clientMessageId !== clientMessageId || consumed.envelopeDigest !== envelopeDigest)) {
1802
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_prekey_consumption_conflict");
1803
+ }
1804
+ if (index < 0) {
1805
+ if (consumed) return false;
1806
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_prekey_consumption_record_missing");
1807
+ }
1808
+ const preKey = state.preKeys[index];
1809
+ if (consumed) {
1810
+ if (
1811
+ preKey.state !== "used"
1812
+ || preKey.usedClientMessageId !== clientMessageId
1813
+ || preKey.usedEnvelopeDigest !== envelopeDigest
1814
+ ) throw new HelloLynkDirectTextE2eeClientError("e2ee_prekey_consumption_conflict");
1815
+ return false;
1816
+ }
1817
+ const acceptedExpiry = iso(claimExpiresAt, "e2ee_prekey_claim_expiry_invalid");
1818
+ if (Date.parse(acceptedExpiry) <= nowMs) {
1819
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_prekey_claim_expired");
1820
+ }
1821
+ if (preKey.state === "used" && (
1822
+ (preKey.usedClientMessageId != null && preKey.usedClientMessageId !== clientMessageId)
1823
+ || (preKey.usedEnvelopeDigest != null && preKey.usedEnvelopeDigest !== envelopeDigest)
1824
+ )) throw new HelloLynkDirectTextE2eeClientError("e2ee_prekey_consumption_conflict");
1825
+ preKey.state = "used";
1826
+ preKey.claimExpiresAt = new Date(Math.max(
1827
+ Date.parse(preKey.claimExpiresAt || acceptedExpiry),
1828
+ Date.parse(acceptedExpiry),
1829
+ )).toISOString();
1830
+ preKey.usedClientMessageId = clientMessageId;
1831
+ preKey.usedEnvelopeDigest = envelopeDigest;
1832
+ if (state.consumedPreKeys.length >= MAX_CONSUMED_RECORDS) state.consumedPreKeys.shift();
1833
+ state.consumedPreKeys.push({
1834
+ preKeyId,
1835
+ clientMessageId,
1836
+ envelopeDigest,
1837
+ consumedAt: new Date(nowMs).toISOString(),
1838
+ });
1839
+ return true;
1840
+ }
1841
+
1842
+ function createLocalPreKey(nowMs) {
1843
+ return {
1844
+ state: "pending_registration",
1845
+ privateKeyBundle: createOneTimePreKey({ nowMs }),
1846
+ registeredAt: null,
1847
+ claimExpiresAt: null,
1848
+ usedClientMessageId: null,
1849
+ usedEnvelopeDigest: null,
1850
+ };
1851
+ }
1852
+
1853
+ function publicPrepared(record) {
1854
+ return {
1855
+ ...(record.e2eeVersion === 2 ? {
1856
+ version: "2",
1857
+ routeKind: record.routeKind,
1858
+ recipientDelegateId: record.recipientDelegateId,
1859
+ recipientHostId: record.recipientHostId,
1860
+ recipientDeviceId: record.recipientDeviceId,
1861
+ ...(record.nativeContentVersion === 1 ? { nativeContentVersion: 1 } : {}),
1862
+ } : {}),
1863
+ claimId: record.claimId,
1864
+ envelope: structuredClone(record.envelope),
1865
+ envelopeDigest: record.envelopeDigest,
1866
+ };
1867
+ }
1868
+
1869
+ function digestRequestV2(input) {
1870
+ return createHash("sha256").update(JSON.stringify({
1871
+ e2eeVersion: 2,
1872
+ clientMessageId: input.clientMessageId,
1873
+ routeKind: input.routeKind,
1874
+ recipientHelloLynkId: input.recipientHelloLynkId,
1875
+ recipientDelegateId: input.recipientDelegateId,
1876
+ recipientHostId: input.recipientHostId,
1877
+ recipientDeviceId: input.recipientDeviceId,
1878
+ senderPrincipalId: input.senderPrincipalId,
1879
+ senderDelegateId: input.senderDelegateId,
1880
+ intentSource: input.intentSource,
1881
+ text: input.text,
1882
+ ...(input.replyToClientMessageId ? { replyToClientMessageId: input.replyToClientMessageId } : {}),
1883
+ ...(input.targetConversationId ? { targetConversationId: input.targetConversationId } : {}),
1884
+ ...(input.nativeDirectory ? { nativeDirectory: input.nativeDirectory } : {}),
1885
+ }), "utf8").digest("hex");
1886
+ }
1887
+
1888
+ function digestRequest(input) {
1889
+ return createHash("sha256").update(JSON.stringify({
1890
+ clientMessageId: input.clientMessageId,
1891
+ recipientHelloLynkId: input.recipientHelloLynkId,
1892
+ senderPrincipalId: input.senderPrincipalId,
1893
+ senderDelegateId: input.senderDelegateId,
1894
+ intentSource: input.intentSource,
1895
+ text: input.text,
1896
+ }), "utf8").digest("hex");
1897
+ }
1898
+
1899
+ async function saveState(privateKeys, profileId, state) {
1900
+ try {
1901
+ await privateKeys.save(profileId, state);
1902
+ } catch (error) {
1903
+ throw clientFailure(error, "e2ee_private_key_save_failed");
1904
+ }
1905
+ }
1906
+
1907
+ async function readBoundedJson(response) {
1908
+ const declared = Number(response.headers?.get?.("content-length"));
1909
+ if (Number.isFinite(declared) && declared > MAX_JSON_RESPONSE_BYTES) {
1910
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_response_too_large");
1911
+ }
1912
+ const raw = await response.text();
1913
+ if (Buffer.byteLength(raw, "utf8") > MAX_JSON_RESPONSE_BYTES) {
1914
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_response_too_large");
1915
+ }
1916
+ try {
1917
+ return raw ? JSON.parse(raw) : {};
1918
+ } catch (error) {
1919
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_response_invalid_json", { cause: error });
1920
+ }
1921
+ }
1922
+
1923
+ function safeProfileId(value) {
1924
+ const text = String(value || "").trim();
1925
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$/.test(text)) {
1926
+ throw new HelloLynkDirectTextE2eeClientError("e2ee_profile_id_invalid");
1927
+ }
1928
+ return text;
1929
+ }
1930
+
1931
+ function safeId(value, code) {
1932
+ const text = String(value || "").trim();
1933
+ if (!SAFE_ID.test(text)) throw new HelloLynkDirectTextE2eeClientError(code);
1934
+ return text;
1935
+ }
1936
+
1937
+ function iso(value, code) {
1938
+ const text = String(value || "");
1939
+ const timestamp = Date.parse(text);
1940
+ if (!Number.isFinite(timestamp) || new Date(timestamp).toISOString() !== text) {
1941
+ throw new HelloLynkDirectTextE2eeClientError(code);
1942
+ }
1943
+ return text;
1944
+ }
1945
+
1946
+ function requireExactFields(value, fields, code) {
1947
+ assertPlainObject(value, code);
1948
+ const actual = Object.keys(value);
1949
+ if (actual.length !== fields.length || fields.some((field) => !Object.hasOwn(value, field))) {
1950
+ throw new HelloLynkDirectTextE2eeClientError(code);
1951
+ }
1952
+ }
1953
+
1954
+ function assertPlainObject(value, code) {
1955
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1956
+ throw new HelloLynkDirectTextE2eeClientError(code);
1957
+ }
1958
+ }
1959
+
1960
+ function clientFailure(error, fallback) {
1961
+ if (error instanceof HelloLynkDirectTextE2eeClientError) return error;
1962
+ const code = /^[a-z][a-z0-9_]{0,63}$/.test(String(error?.code || "")) ? error.code : fallback;
1963
+ return new HelloLynkDirectTextE2eeClientError(code, { cause: error });
1964
+ }