@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/LICENSE +92 -0
- package/README.md +101 -2
- package/bin/hellolynk-client-setup.mjs +2748 -0
- package/bin/hellolynk-mcp-config-shape.mjs +23 -0
- package/hellolynk-client-pack-manifest.json +619 -0
- package/hellolynk-client-pack-pin.json +4 -0
- package/package.json +58 -4
- package/plugins/hellolynk-codex/.app.json +3 -0
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
- package/plugins/hellolynk-codex/.mcp.json +19 -0
- package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/package.json +11 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +277 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
- package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
- package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
- package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
- package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
- package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
- package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
- package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
- package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
- package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
- package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
- package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
- package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
- package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
- package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
- package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
- package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
- package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
- package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
|
@@ -0,0 +1,1733 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
link,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
open,
|
|
9
|
+
readFile,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
import { defaultHelloLynkClientRoot } from "./hellolynk-client-profile.mjs";
|
|
16
|
+
import {
|
|
17
|
+
CONTACT_REQUEST_E2EE_AUTHORIZATION_SCOPE,
|
|
18
|
+
CONTACT_REQUEST_E2EE_INTENT_SOURCE,
|
|
19
|
+
CONTACT_REQUEST_E2EE_MAX_TTL_MS,
|
|
20
|
+
CONTACT_REQUEST_E2EE_MESSAGE_TYPE,
|
|
21
|
+
contactRequestIntroReplayKey,
|
|
22
|
+
createContactRequestIntroCommitment,
|
|
23
|
+
createContactRequestRecordDigest,
|
|
24
|
+
validateContactRequestIntro,
|
|
25
|
+
} from "./hellolynk-contact-request-e2ee.mjs";
|
|
26
|
+
import { assertLocalDataProtectionAccess } from "./hellolynk-local-data-protection.mjs";
|
|
27
|
+
|
|
28
|
+
export const LOCAL_CONTACT_REQUEST_STORE_CONTRACT = "hellolynk-contact-request-local-store-v2";
|
|
29
|
+
|
|
30
|
+
const STORE_SCHEMA_VERSION = 2;
|
|
31
|
+
const LEGACY_STORE_SCHEMA_VERSION = 1;
|
|
32
|
+
const STORE_DIRECTORY_NAME = "contact-requests";
|
|
33
|
+
const DEFAULT_MAX_STORE_BYTES = 2 * 1024 * 1024;
|
|
34
|
+
const MIN_MAX_STORE_BYTES = 4 * 1024;
|
|
35
|
+
const DEFAULT_MAX_RECORDS = 500;
|
|
36
|
+
const MAX_CONFIGURED_RECORDS = 2_000;
|
|
37
|
+
const MAX_LOCK_BYTES = 16 * 1024;
|
|
38
|
+
const LOCK_WAIT_MS = 20;
|
|
39
|
+
const LOCK_ATTEMPTS = 100;
|
|
40
|
+
const LOCK_STALE_MS = 5_000;
|
|
41
|
+
const WINDOWS_TRANSIENT_FS_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
|
|
42
|
+
const WINDOWS_TRANSIENT_FS_RETRY_MS = Object.freeze([0, 5, 10, 25, 50, 100, 200]);
|
|
43
|
+
const MAX_RELAY_CLOCK_SKEW_MS = 5 * 60_000;
|
|
44
|
+
const MAX_ENVELOPE_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
45
|
+
const MAX_ACK_WINDOW_MS = 60_000;
|
|
46
|
+
const LOCAL_RECORD_DIGEST_DOMAIN = "hellolynk/contact-request/local-record/v2\0";
|
|
47
|
+
const PROFILE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$/;
|
|
48
|
+
const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,199}$/;
|
|
49
|
+
const LOCAL_RECORD_ID_RE = /^local_contact_request_[a-f0-9-]{36}$/;
|
|
50
|
+
const HELLOLYNK_ID_RE = /^hellolynk:(intl|cn):[a-z0-9][a-z0-9._-]{2,179}$/;
|
|
51
|
+
const SHA256_RE = /^[a-f0-9]{64}$/;
|
|
52
|
+
const BASE64URL_16_RE = /^[A-Za-z0-9_-]{22}$/;
|
|
53
|
+
const UNSAFE_DISPLAY_RE = /[\u0000-\u001F\u007F-\u009F\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]/u;
|
|
54
|
+
const STORE_KEYS = Object.freeze(["schemaVersion", "contractId", "profileId", "records"]);
|
|
55
|
+
const INCOMING_KEYS = Object.freeze([
|
|
56
|
+
"eventId",
|
|
57
|
+
"attemptId",
|
|
58
|
+
"clientRequestId",
|
|
59
|
+
"connectionId",
|
|
60
|
+
"claimId",
|
|
61
|
+
"replayKey",
|
|
62
|
+
"envelopeDigest",
|
|
63
|
+
"requestDigest",
|
|
64
|
+
"introDigest",
|
|
65
|
+
"introDigestSalt",
|
|
66
|
+
"introWeight",
|
|
67
|
+
"introText",
|
|
68
|
+
"senderPrincipalId",
|
|
69
|
+
"senderHelloLynkId",
|
|
70
|
+
"senderPrincipalDisplayName",
|
|
71
|
+
"senderDeviceId",
|
|
72
|
+
"senderIdentityKeyId",
|
|
73
|
+
"recipientPrincipalId",
|
|
74
|
+
"recipientHelloLynkId",
|
|
75
|
+
"recipientDeviceId",
|
|
76
|
+
"recipientIdentityKeyId",
|
|
77
|
+
"recipientPreKeyId",
|
|
78
|
+
"createdAt",
|
|
79
|
+
"expiresAt",
|
|
80
|
+
"relayedAt",
|
|
81
|
+
"ackDeadlineAt",
|
|
82
|
+
]);
|
|
83
|
+
const RECORD_KEYS = Object.freeze([
|
|
84
|
+
"localRecordId",
|
|
85
|
+
"localRecordDigest",
|
|
86
|
+
...INCOMING_KEYS,
|
|
87
|
+
"state",
|
|
88
|
+
"serverContactRequestId",
|
|
89
|
+
"serverUpdatedAt",
|
|
90
|
+
"decisionAttempt",
|
|
91
|
+
"decisionRecoveryRequired",
|
|
92
|
+
"receivedAt",
|
|
93
|
+
"updatedAt",
|
|
94
|
+
]);
|
|
95
|
+
const LEGACY_RECORD_KEYS = Object.freeze(RECORD_KEYS.filter((key) => (
|
|
96
|
+
key !== "decisionAttempt" && key !== "decisionRecoveryRequired"
|
|
97
|
+
)));
|
|
98
|
+
const PROJECTION_KEYS = Object.freeze([
|
|
99
|
+
"localRecordId",
|
|
100
|
+
"expectedLocalRecordDigest",
|
|
101
|
+
"serverContactRequestId",
|
|
102
|
+
"state",
|
|
103
|
+
"serverUpdatedAt",
|
|
104
|
+
]);
|
|
105
|
+
const HUMAN_LIST_OPTION_KEYS = Object.freeze(["states", "limit"]);
|
|
106
|
+
const PROJECTION_STATES = new Set([
|
|
107
|
+
"pending", "accepted", "rejected", "cancelled", "blocked", "expired",
|
|
108
|
+
]);
|
|
109
|
+
const FINAL_PROJECTION_STATES = new Set([
|
|
110
|
+
"accepted", "rejected", "cancelled", "blocked", "expired",
|
|
111
|
+
]);
|
|
112
|
+
const DECISION_ACTIONS = new Set(["accept", "reject", "block"]);
|
|
113
|
+
const DECISION_ATTEMPT_KEYS = Object.freeze(["decisionId", "action", "startedAt"]);
|
|
114
|
+
const BEGIN_DECISION_KEYS = Object.freeze([
|
|
115
|
+
"localRecordId",
|
|
116
|
+
"expectedLocalRecordDigest",
|
|
117
|
+
"serverContactRequestId",
|
|
118
|
+
"action",
|
|
119
|
+
]);
|
|
120
|
+
const ABORT_DECISION_KEYS = Object.freeze([
|
|
121
|
+
"localRecordId",
|
|
122
|
+
"expectedLocalRecordDigest",
|
|
123
|
+
"serverContactRequestId",
|
|
124
|
+
"decisionId",
|
|
125
|
+
]);
|
|
126
|
+
const RECOVER_DECISION_KEYS = ABORT_DECISION_KEYS;
|
|
127
|
+
const RESOLVE_DECISION_KEYS = Object.freeze([
|
|
128
|
+
"localRecordId",
|
|
129
|
+
"expectedLocalRecordDigest",
|
|
130
|
+
"serverContactRequestId",
|
|
131
|
+
"decisionId",
|
|
132
|
+
"state",
|
|
133
|
+
"serverUpdatedAt",
|
|
134
|
+
]);
|
|
135
|
+
const pathMutationQueues = new Map();
|
|
136
|
+
|
|
137
|
+
export class HelloLynkLocalContactRequestsError extends Error {
|
|
138
|
+
constructor(code, options = {}) {
|
|
139
|
+
super(code, options.cause ? { cause: options.cause } : undefined);
|
|
140
|
+
this.name = "HelloLynkLocalContactRequestsError";
|
|
141
|
+
this.code = code;
|
|
142
|
+
this.status = Number.isInteger(options.status) ? options.status : null;
|
|
143
|
+
this.retryable = options.retryable === true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function resolveLocalContactRequestsPath(options = {}) {
|
|
148
|
+
assertPlainRecord(options, "contact_request_store_options_invalid");
|
|
149
|
+
assertOnlyKnownFields(options, ["profileId", "root"], "contact_request_store_options_invalid");
|
|
150
|
+
const profileId = normalizeProfileId(options.profileId);
|
|
151
|
+
const root = path.resolve(options.root || defaultHelloLynkClientRoot());
|
|
152
|
+
return path.join(root, STORE_DIRECTORY_NAME, `${profileId}.json`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function createLocalContactRequestStore(options = {}) {
|
|
156
|
+
assertPlainRecord(options, "contact_request_store_options_invalid");
|
|
157
|
+
assertOnlyKnownFields(
|
|
158
|
+
options,
|
|
159
|
+
["profileId", "root", "storePath", "now", "maxStoreBytes", "maxRecords", "dataProtector"],
|
|
160
|
+
"contact_request_store_options_invalid",
|
|
161
|
+
);
|
|
162
|
+
const profileId = normalizeProfileId(options.profileId);
|
|
163
|
+
if (options.root != null && options.storePath != null) {
|
|
164
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_store_path_options_conflict");
|
|
165
|
+
}
|
|
166
|
+
const storePath = path.resolve(options.storePath || resolveLocalContactRequestsPath({
|
|
167
|
+
profileId,
|
|
168
|
+
root: options.root,
|
|
169
|
+
}));
|
|
170
|
+
const layout = resolveStoreLayout(storePath, profileId);
|
|
171
|
+
const now = typeof options.now === "function" ? options.now : () => Date.now();
|
|
172
|
+
const maximumBytes = normalizeMaximumBytes(options.maxStoreBytes);
|
|
173
|
+
const maximumRecords = normalizeMaximumRecords(options.maxRecords);
|
|
174
|
+
const protection = normalizeContactRequestDataProtection(options.dataProtector, profileId);
|
|
175
|
+
|
|
176
|
+
async function mutate(callback) {
|
|
177
|
+
return withPathMutationQueue(storePath, async () => {
|
|
178
|
+
const store = await readStore(layout, profileId, maximumBytes, maximumRecords, protection);
|
|
179
|
+
const result = await callback(store);
|
|
180
|
+
await atomicWriteStore(layout, store, maximumBytes, protection);
|
|
181
|
+
return result;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function persistIncoming(input) {
|
|
186
|
+
const accepted = normalizeIncoming(input);
|
|
187
|
+
const receivedAt = normalizeCurrentTimestamp(now());
|
|
188
|
+
assertCurrentReceiveWindow(accepted, Date.parse(receivedAt));
|
|
189
|
+
return mutate((store) => {
|
|
190
|
+
const replay = store.records.find((record) => record.replayKey === accepted.replayKey);
|
|
191
|
+
if (replay) {
|
|
192
|
+
if (replay.envelopeDigest !== accepted.envelopeDigest) {
|
|
193
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_replay_conflict", {
|
|
194
|
+
status: 409,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return acknowledgementReceipt(replay, true);
|
|
198
|
+
}
|
|
199
|
+
const conflictingOuterIdentity = store.records.find((record) => (
|
|
200
|
+
record.eventId === accepted.eventId
|
|
201
|
+
|| record.attemptId === accepted.attemptId
|
|
202
|
+
|| (
|
|
203
|
+
record.senderPrincipalId === accepted.senderPrincipalId
|
|
204
|
+
&& record.clientRequestId === accepted.clientRequestId
|
|
205
|
+
)
|
|
206
|
+
));
|
|
207
|
+
if (conflictingOuterIdentity) {
|
|
208
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_identity_conflict", {
|
|
209
|
+
status: 409,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (store.records.length >= maximumRecords) {
|
|
213
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_capacity_exceeded", {
|
|
214
|
+
status: 507,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const localRecordId = `local_contact_request_${crypto.randomUUID()}`;
|
|
218
|
+
const record = {
|
|
219
|
+
localRecordId,
|
|
220
|
+
localRecordDigest: "",
|
|
221
|
+
...accepted,
|
|
222
|
+
state: "pending",
|
|
223
|
+
serverContactRequestId: null,
|
|
224
|
+
serverUpdatedAt: null,
|
|
225
|
+
decisionAttempt: null,
|
|
226
|
+
decisionRecoveryRequired: false,
|
|
227
|
+
receivedAt,
|
|
228
|
+
updatedAt: receivedAt,
|
|
229
|
+
};
|
|
230
|
+
record.localRecordDigest = digestImmutableRecord(record);
|
|
231
|
+
store.records.push(record);
|
|
232
|
+
return acknowledgementReceipt(record, false);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function projectServerState(input) {
|
|
237
|
+
const projection = normalizeProjection(input);
|
|
238
|
+
return mutate((store) => {
|
|
239
|
+
const record = store.records.find((item) => item.localRecordId === projection.localRecordId);
|
|
240
|
+
if (!record) {
|
|
241
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_record_not_found", {
|
|
242
|
+
status: 404,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (record.localRecordDigest !== projection.expectedLocalRecordDigest) {
|
|
246
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_record_digest_mismatch", {
|
|
247
|
+
status: 409,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (
|
|
251
|
+
record.serverContactRequestId != null
|
|
252
|
+
&& record.serverContactRequestId !== projection.serverContactRequestId
|
|
253
|
+
) {
|
|
254
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_server_id_conflict", {
|
|
255
|
+
status: 409,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
if (record.decisionAttempt != null) {
|
|
259
|
+
if (record.state === "pending" && projection.state === "pending") {
|
|
260
|
+
return projectionReceipt(record, true);
|
|
261
|
+
}
|
|
262
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_decision_unresolved", {
|
|
263
|
+
status: 409,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (record.decisionRecoveryRequired) {
|
|
267
|
+
if (record.state === "pending" && projection.state === "pending") {
|
|
268
|
+
return projectionReceipt(record, true);
|
|
269
|
+
}
|
|
270
|
+
if (!FINAL_PROJECTION_STATES.has(projection.state)) {
|
|
271
|
+
throw new HelloLynkLocalContactRequestsError(
|
|
272
|
+
"contact_request_local_decision_recovery_required",
|
|
273
|
+
{ status: 409 },
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (record.state === projection.state) {
|
|
278
|
+
if (record.serverContactRequestId == null) {
|
|
279
|
+
record.serverContactRequestId = projection.serverContactRequestId;
|
|
280
|
+
record.serverUpdatedAt = projection.serverUpdatedAt;
|
|
281
|
+
record.updatedAt = normalizeCurrentTimestamp(now());
|
|
282
|
+
return projectionReceipt(record, false);
|
|
283
|
+
}
|
|
284
|
+
return projectionReceipt(record, true);
|
|
285
|
+
}
|
|
286
|
+
const transitionAllowed = (
|
|
287
|
+
(record.state === "pending" && FINAL_PROJECTION_STATES.has(projection.state))
|
|
288
|
+
|| (record.state !== "blocked" && projection.state === "blocked")
|
|
289
|
+
);
|
|
290
|
+
if (!transitionAllowed) {
|
|
291
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_state_conflict", {
|
|
292
|
+
status: 409,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
record.serverContactRequestId = projection.serverContactRequestId;
|
|
296
|
+
record.state = projection.state;
|
|
297
|
+
record.serverUpdatedAt = projection.serverUpdatedAt;
|
|
298
|
+
record.decisionRecoveryRequired = false;
|
|
299
|
+
record.updatedAt = normalizeCurrentTimestamp(now());
|
|
300
|
+
return projectionReceipt(record, false);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function beginDecision(input) {
|
|
305
|
+
const accepted = normalizeBeginDecision(input);
|
|
306
|
+
return mutate((store) => {
|
|
307
|
+
const record = requireDecisionRecord(store, accepted);
|
|
308
|
+
if (record.state !== "pending" || record.serverContactRequestId == null) {
|
|
309
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_decision_not_pending", {
|
|
310
|
+
status: 409,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
if (record.decisionRecoveryRequired) {
|
|
314
|
+
throw new HelloLynkLocalContactRequestsError(
|
|
315
|
+
"contact_request_local_decision_recovery_required",
|
|
316
|
+
{ status: 409 },
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
if (record.decisionAttempt != null) {
|
|
320
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_decision_unresolved", {
|
|
321
|
+
status: 409,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
const decisionAttempt = {
|
|
325
|
+
decisionId: `contact_decision_${crypto.randomUUID()}`,
|
|
326
|
+
action: accepted.action,
|
|
327
|
+
startedAt: normalizeCurrentTimestamp(now()),
|
|
328
|
+
};
|
|
329
|
+
record.decisionAttempt = decisionAttempt;
|
|
330
|
+
record.updatedAt = decisionAttempt.startedAt;
|
|
331
|
+
return decisionAttemptReceipt(record);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function abortDecision(input) {
|
|
336
|
+
const accepted = normalizeAbortDecision(input);
|
|
337
|
+
return mutate((store) => {
|
|
338
|
+
const record = requireDecisionRecord(store, accepted);
|
|
339
|
+
assertDecisionCas(record, accepted.decisionId);
|
|
340
|
+
if (record.state !== "pending") {
|
|
341
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_decision_not_pending", {
|
|
342
|
+
status: 409,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
record.decisionAttempt = null;
|
|
346
|
+
record.decisionRecoveryRequired = false;
|
|
347
|
+
record.updatedAt = normalizeCurrentTimestamp(now());
|
|
348
|
+
return decisionAttemptReceipt(record);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function resolveDecision(input) {
|
|
353
|
+
const accepted = normalizeResolveDecision(input);
|
|
354
|
+
return mutate((store) => {
|
|
355
|
+
const record = requireDecisionRecord(store, accepted);
|
|
356
|
+
assertDecisionCas(record, accepted.decisionId);
|
|
357
|
+
if (record.state !== "pending") {
|
|
358
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_decision_not_pending", {
|
|
359
|
+
status: 409,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
record.state = accepted.state;
|
|
363
|
+
record.serverUpdatedAt = accepted.serverUpdatedAt;
|
|
364
|
+
record.decisionAttempt = null;
|
|
365
|
+
record.decisionRecoveryRequired = false;
|
|
366
|
+
record.updatedAt = normalizeCurrentTimestamp(now());
|
|
367
|
+
return projectionReceipt(record, false);
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function recoverDecision(input) {
|
|
372
|
+
const accepted = normalizeRecoverDecision(input);
|
|
373
|
+
return mutate((store) => {
|
|
374
|
+
const record = requireDecisionRecord(store, accepted);
|
|
375
|
+
if (record.state !== "pending" || record.serverContactRequestId == null) {
|
|
376
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_decision_not_pending", {
|
|
377
|
+
status: 409,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (record.decisionRecoveryRequired) {
|
|
381
|
+
if (accepted.decisionId !== null || record.decisionAttempt != null) {
|
|
382
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_decision_cas_conflict", {
|
|
383
|
+
status: 409,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
} else if (record.decisionAttempt != null) {
|
|
387
|
+
assertDecisionCas(record, accepted.decisionId);
|
|
388
|
+
} else {
|
|
389
|
+
throw new HelloLynkLocalContactRequestsError(
|
|
390
|
+
"contact_request_local_decision_recovery_not_required",
|
|
391
|
+
{ status: 409 },
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
record.decisionAttempt = null;
|
|
395
|
+
record.decisionRecoveryRequired = false;
|
|
396
|
+
record.updatedAt = normalizeCurrentTimestamp(now());
|
|
397
|
+
return decisionAttemptReceipt(record);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function listUnresolvedDecisions() {
|
|
402
|
+
const store = await readStore(layout, profileId, maximumBytes, maximumRecords, protection);
|
|
403
|
+
return store.records
|
|
404
|
+
.filter((record) => record.decisionAttempt != null || record.decisionRecoveryRequired)
|
|
405
|
+
.sort((left, right) => left.receivedAt.localeCompare(right.receivedAt))
|
|
406
|
+
.map(unresolvedDecisionProjection);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function listForHuman(input = {}) {
|
|
410
|
+
assertPlainRecord(input, "contact_request_human_list_options_invalid");
|
|
411
|
+
assertOnlyKnownFields(input, HUMAN_LIST_OPTION_KEYS, "contact_request_human_list_options_invalid");
|
|
412
|
+
const states = normalizeHumanStates(input.states);
|
|
413
|
+
const limit = normalizeHumanLimit(input.limit, maximumRecords);
|
|
414
|
+
const store = await readStore(layout, profileId, maximumBytes, maximumRecords, protection);
|
|
415
|
+
return store.records
|
|
416
|
+
.filter((record) => states.has(record.state))
|
|
417
|
+
.sort((left, right) => right.receivedAt.localeCompare(left.receivedAt))
|
|
418
|
+
.slice(0, limit)
|
|
419
|
+
.map(humanProjection);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function snapshotMetadata() {
|
|
423
|
+
const store = await readStore(layout, profileId, maximumBytes, maximumRecords, protection);
|
|
424
|
+
const counts = Object.fromEntries([...PROJECTION_STATES].map((state) => [state, 0]));
|
|
425
|
+
for (const record of store.records) counts[record.state] += 1;
|
|
426
|
+
return Object.freeze({
|
|
427
|
+
schemaVersion: STORE_SCHEMA_VERSION,
|
|
428
|
+
contractId: LOCAL_CONTACT_REQUEST_STORE_CONTRACT,
|
|
429
|
+
profileId,
|
|
430
|
+
count: store.records.length,
|
|
431
|
+
counts: Object.freeze(counts),
|
|
432
|
+
storage: "local_file",
|
|
433
|
+
humanOnly: true,
|
|
434
|
+
modelVisibility: "none",
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function clearResolvedForHuman() {
|
|
439
|
+
return mutate((store) => {
|
|
440
|
+
const retained = store.records.filter((record) => !(
|
|
441
|
+
FINAL_PROJECTION_STATES.has(record.state)
|
|
442
|
+
&& record.serverContactRequestId != null
|
|
443
|
+
));
|
|
444
|
+
const deletedCount = store.records.length - retained.length;
|
|
445
|
+
store.records = retained;
|
|
446
|
+
return Object.freeze({ deletedCount });
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return Object.freeze({
|
|
451
|
+
schemaVersion: STORE_SCHEMA_VERSION,
|
|
452
|
+
contractId: LOCAL_CONTACT_REQUEST_STORE_CONTRACT,
|
|
453
|
+
profileId,
|
|
454
|
+
path: storePath,
|
|
455
|
+
persistIncoming,
|
|
456
|
+
projectServerState,
|
|
457
|
+
beginDecision,
|
|
458
|
+
abortDecision,
|
|
459
|
+
resolveDecision,
|
|
460
|
+
recoverDecision,
|
|
461
|
+
listUnresolvedDecisions,
|
|
462
|
+
listForHuman,
|
|
463
|
+
listPendingForHuman: () => listForHuman({ states: ["pending"] }),
|
|
464
|
+
listPendingAckReceipts: async () => {
|
|
465
|
+
const store = await readStore(layout, profileId, maximumBytes, maximumRecords, protection);
|
|
466
|
+
return store.records
|
|
467
|
+
.filter((record) => record.serverContactRequestId == null)
|
|
468
|
+
.sort((left, right) => left.receivedAt.localeCompare(right.receivedAt))
|
|
469
|
+
.map((record) => acknowledgementReceipt(record, true));
|
|
470
|
+
},
|
|
471
|
+
snapshotMetadata,
|
|
472
|
+
clearResolvedForHuman,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function emptyStore(profileId) {
|
|
477
|
+
return {
|
|
478
|
+
schemaVersion: STORE_SCHEMA_VERSION,
|
|
479
|
+
contractId: LOCAL_CONTACT_REQUEST_STORE_CONTRACT,
|
|
480
|
+
profileId,
|
|
481
|
+
records: [],
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function normalizeIncoming(input) {
|
|
486
|
+
assertExactFields(input, INCOMING_KEYS, "contact_request_local_input_invalid");
|
|
487
|
+
let acceptedIntro;
|
|
488
|
+
try {
|
|
489
|
+
acceptedIntro = validateContactRequestIntro(input.introText);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
throw new HelloLynkLocalContactRequestsError(
|
|
492
|
+
String(error?.code || "contact_request_intro_invalid"),
|
|
493
|
+
{ cause: error },
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
const introDigestSalt = normalizeDigestSalt(input.introDigestSalt);
|
|
497
|
+
let commitment;
|
|
498
|
+
try {
|
|
499
|
+
commitment = createContactRequestIntroCommitment(acceptedIntro.text, { introDigestSalt });
|
|
500
|
+
} catch (error) {
|
|
501
|
+
throw new HelloLynkLocalContactRequestsError(
|
|
502
|
+
String(error?.code || "contact_request_local_intro_commitment_invalid"),
|
|
503
|
+
{ cause: error },
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const introDigest = normalizeDigest(input.introDigest, "contact_request_intro_digest_invalid");
|
|
507
|
+
const introWeight = input.introWeight;
|
|
508
|
+
if (commitment.introDigest !== introDigest || commitment.introWeight !== introWeight) {
|
|
509
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_intro_commitment_mismatch", {
|
|
510
|
+
status: 409,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
const recipientPrincipalId = normalizeId(
|
|
514
|
+
input.recipientPrincipalId,
|
|
515
|
+
"contact_request_recipient_principal_invalid",
|
|
516
|
+
);
|
|
517
|
+
const requestDigest = normalizeDigest(input.requestDigest, "contact_request_request_digest_invalid");
|
|
518
|
+
let expectedRequestDigest;
|
|
519
|
+
try {
|
|
520
|
+
expectedRequestDigest = createContactRequestRecordDigest({
|
|
521
|
+
recipientPrincipalId,
|
|
522
|
+
introDigest,
|
|
523
|
+
introWeight,
|
|
524
|
+
});
|
|
525
|
+
} catch (error) {
|
|
526
|
+
throw new HelloLynkLocalContactRequestsError(
|
|
527
|
+
String(error?.code || "contact_request_local_request_digest_invalid"),
|
|
528
|
+
{ cause: error },
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
if (expectedRequestDigest !== requestDigest) {
|
|
532
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_request_digest_mismatch", {
|
|
533
|
+
status: 409,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
const accepted = {
|
|
537
|
+
eventId: normalizeId(input.eventId, "contact_request_event_id_invalid"),
|
|
538
|
+
attemptId: normalizeId(input.attemptId, "contact_request_attempt_id_invalid"),
|
|
539
|
+
clientRequestId: normalizeId(input.clientRequestId, "contact_request_client_id_invalid"),
|
|
540
|
+
connectionId: normalizeId(
|
|
541
|
+
input.connectionId,
|
|
542
|
+
"contact_request_connection_id_invalid",
|
|
543
|
+
),
|
|
544
|
+
claimId: normalizeId(input.claimId, "contact_request_claim_id_invalid"),
|
|
545
|
+
replayKey: normalizeDigest(input.replayKey, "contact_request_replay_key_invalid"),
|
|
546
|
+
envelopeDigest: normalizeDigest(
|
|
547
|
+
input.envelopeDigest,
|
|
548
|
+
"contact_request_envelope_digest_invalid",
|
|
549
|
+
),
|
|
550
|
+
requestDigest,
|
|
551
|
+
introDigest,
|
|
552
|
+
introDigestSalt,
|
|
553
|
+
introWeight,
|
|
554
|
+
introText: acceptedIntro.text,
|
|
555
|
+
senderPrincipalId: normalizeId(
|
|
556
|
+
input.senderPrincipalId,
|
|
557
|
+
"contact_request_sender_principal_invalid",
|
|
558
|
+
),
|
|
559
|
+
senderHelloLynkId: normalizeHelloLynkId(input.senderHelloLynkId, "contact_request_sender_hellolynk_id_invalid"),
|
|
560
|
+
senderPrincipalDisplayName: normalizeOptionalDisplayName(input.senderPrincipalDisplayName),
|
|
561
|
+
senderDeviceId: normalizeId(input.senderDeviceId, "contact_request_sender_device_invalid"),
|
|
562
|
+
senderIdentityKeyId: normalizeId(
|
|
563
|
+
input.senderIdentityKeyId,
|
|
564
|
+
"contact_request_sender_identity_invalid",
|
|
565
|
+
),
|
|
566
|
+
recipientPrincipalId,
|
|
567
|
+
recipientHelloLynkId: normalizeHelloLynkId(
|
|
568
|
+
input.recipientHelloLynkId,
|
|
569
|
+
"contact_request_recipient_hellolynk_id_invalid",
|
|
570
|
+
),
|
|
571
|
+
recipientDeviceId: normalizeId(
|
|
572
|
+
input.recipientDeviceId,
|
|
573
|
+
"contact_request_recipient_device_invalid",
|
|
574
|
+
),
|
|
575
|
+
recipientIdentityKeyId: normalizeId(
|
|
576
|
+
input.recipientIdentityKeyId,
|
|
577
|
+
"contact_request_recipient_identity_invalid",
|
|
578
|
+
),
|
|
579
|
+
recipientPreKeyId: normalizeId(
|
|
580
|
+
input.recipientPreKeyId,
|
|
581
|
+
"contact_request_recipient_prekey_invalid",
|
|
582
|
+
),
|
|
583
|
+
createdAt: normalizeTimestamp(input.createdAt, "contact_request_created_at_invalid"),
|
|
584
|
+
expiresAt: normalizeTimestamp(input.expiresAt, "contact_request_expires_at_invalid"),
|
|
585
|
+
relayedAt: normalizeTimestamp(input.relayedAt, "contact_request_relayed_at_invalid"),
|
|
586
|
+
ackDeadlineAt: normalizeTimestamp(
|
|
587
|
+
input.ackDeadlineAt,
|
|
588
|
+
"contact_request_ack_deadline_invalid",
|
|
589
|
+
),
|
|
590
|
+
};
|
|
591
|
+
if (accepted.senderPrincipalId === accepted.recipientPrincipalId) {
|
|
592
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_self_target_invalid");
|
|
593
|
+
}
|
|
594
|
+
const createdAtMs = Date.parse(accepted.createdAt);
|
|
595
|
+
const expiresAtMs = Date.parse(accepted.expiresAt);
|
|
596
|
+
const relayedAtMs = Date.parse(accepted.relayedAt);
|
|
597
|
+
const ackDeadlineAtMs = Date.parse(accepted.ackDeadlineAt);
|
|
598
|
+
if (
|
|
599
|
+
expiresAtMs <= createdAtMs
|
|
600
|
+
|| expiresAtMs - createdAtMs > CONTACT_REQUEST_E2EE_MAX_TTL_MS
|
|
601
|
+
|| relayedAtMs < createdAtMs - MAX_RELAY_CLOCK_SKEW_MS
|
|
602
|
+
|| relayedAtMs >= expiresAtMs
|
|
603
|
+
|| ackDeadlineAtMs <= relayedAtMs
|
|
604
|
+
|| ackDeadlineAtMs - relayedAtMs > MAX_ACK_WINDOW_MS
|
|
605
|
+
) {
|
|
606
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_timestamp_order_invalid");
|
|
607
|
+
}
|
|
608
|
+
let expectedReplayKey;
|
|
609
|
+
try {
|
|
610
|
+
expectedReplayKey = contactRequestIntroReplayKey({
|
|
611
|
+
attemptId: accepted.attemptId,
|
|
612
|
+
clientRequestId: accepted.clientRequestId,
|
|
613
|
+
requestDigest: accepted.requestDigest,
|
|
614
|
+
introDigest: accepted.introDigest,
|
|
615
|
+
introWeight: accepted.introWeight,
|
|
616
|
+
senderPrincipalId: accepted.senderPrincipalId,
|
|
617
|
+
senderHelloLynkId: accepted.senderHelloLynkId,
|
|
618
|
+
senderDeviceId: accepted.senderDeviceId,
|
|
619
|
+
senderIdentityKeyId: accepted.senderIdentityKeyId,
|
|
620
|
+
recipientPrincipalId: accepted.recipientPrincipalId,
|
|
621
|
+
recipientHelloLynkId: accepted.recipientHelloLynkId,
|
|
622
|
+
recipientDeviceId: accepted.recipientDeviceId,
|
|
623
|
+
recipientIdentityKeyId: accepted.recipientIdentityKeyId,
|
|
624
|
+
recipientPreKeyId: accepted.recipientPreKeyId,
|
|
625
|
+
authorizationScope: CONTACT_REQUEST_E2EE_AUTHORIZATION_SCOPE,
|
|
626
|
+
intentSource: CONTACT_REQUEST_E2EE_INTENT_SOURCE,
|
|
627
|
+
messageType: CONTACT_REQUEST_E2EE_MESSAGE_TYPE,
|
|
628
|
+
createdAt: accepted.createdAt,
|
|
629
|
+
expiresAt: accepted.expiresAt,
|
|
630
|
+
});
|
|
631
|
+
} catch (error) {
|
|
632
|
+
throw new HelloLynkLocalContactRequestsError(
|
|
633
|
+
String(error?.code || "contact_request_local_replay_key_invalid"),
|
|
634
|
+
{ cause: error },
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
if (!constantTimeDigestEqual(expectedReplayKey, accepted.replayKey)) {
|
|
638
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_replay_key_mismatch", {
|
|
639
|
+
status: 409,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
return Object.freeze(accepted);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function assertCurrentReceiveWindow(incoming, nowMs) {
|
|
646
|
+
// The relay ACK deadline only ends the synchronous wait. A delivery_unknown attempt may still
|
|
647
|
+
// be durably persisted and late-ACKed while its signed E2EE envelope remains unexpired.
|
|
648
|
+
if (Date.parse(incoming.expiresAt) <= nowMs) {
|
|
649
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_envelope_expired", {
|
|
650
|
+
status: 409,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
if (
|
|
654
|
+
Date.parse(incoming.createdAt) > nowMs + MAX_ENVELOPE_CLOCK_SKEW_MS
|
|
655
|
+
|| Date.parse(incoming.relayedAt) > nowMs + MAX_ENVELOPE_CLOCK_SKEW_MS
|
|
656
|
+
) {
|
|
657
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_timestamp_in_future", {
|
|
658
|
+
status: 409,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function normalizeStoredRecord(input, schemaVersion) {
|
|
664
|
+
assertExactFields(
|
|
665
|
+
input,
|
|
666
|
+
schemaVersion === LEGACY_STORE_SCHEMA_VERSION ? LEGACY_RECORD_KEYS : RECORD_KEYS,
|
|
667
|
+
"contact_request_local_store_invalid",
|
|
668
|
+
);
|
|
669
|
+
const immutable = normalizeIncoming(Object.fromEntries(
|
|
670
|
+
INCOMING_KEYS.map((key) => [key, input[key]]),
|
|
671
|
+
));
|
|
672
|
+
const record = {
|
|
673
|
+
localRecordId: normalizeLocalRecordId(input.localRecordId),
|
|
674
|
+
localRecordDigest: normalizeDigest(
|
|
675
|
+
input.localRecordDigest,
|
|
676
|
+
"contact_request_local_record_digest_invalid",
|
|
677
|
+
),
|
|
678
|
+
...immutable,
|
|
679
|
+
state: normalizeProjectionState(input.state),
|
|
680
|
+
serverContactRequestId: input.serverContactRequestId == null
|
|
681
|
+
? null
|
|
682
|
+
: normalizeId(
|
|
683
|
+
input.serverContactRequestId,
|
|
684
|
+
"contact_request_server_request_id_invalid",
|
|
685
|
+
),
|
|
686
|
+
serverUpdatedAt: input.serverUpdatedAt == null
|
|
687
|
+
? null
|
|
688
|
+
: normalizeTimestamp(
|
|
689
|
+
input.serverUpdatedAt,
|
|
690
|
+
"contact_request_server_updated_at_invalid",
|
|
691
|
+
),
|
|
692
|
+
decisionAttempt: schemaVersion === LEGACY_STORE_SCHEMA_VERSION
|
|
693
|
+
? null
|
|
694
|
+
: normalizeDecisionAttempt(input.decisionAttempt),
|
|
695
|
+
decisionRecoveryRequired: schemaVersion === LEGACY_STORE_SCHEMA_VERSION
|
|
696
|
+
? false
|
|
697
|
+
: normalizeStoredBoolean(
|
|
698
|
+
input.decisionRecoveryRequired,
|
|
699
|
+
"contact_request_local_decision_recovery_invalid",
|
|
700
|
+
),
|
|
701
|
+
receivedAt: normalizeTimestamp(input.receivedAt, "contact_request_received_at_invalid"),
|
|
702
|
+
updatedAt: normalizeTimestamp(input.updatedAt, "contact_request_updated_at_invalid"),
|
|
703
|
+
};
|
|
704
|
+
if ((record.serverContactRequestId == null) !== (record.serverUpdatedAt == null)) {
|
|
705
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_invalid");
|
|
706
|
+
}
|
|
707
|
+
if (record.state !== "pending" && record.serverContactRequestId == null) {
|
|
708
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_invalid");
|
|
709
|
+
}
|
|
710
|
+
if (record.decisionAttempt != null
|
|
711
|
+
&& (record.state !== "pending" || record.serverContactRequestId == null)) {
|
|
712
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_invalid");
|
|
713
|
+
}
|
|
714
|
+
if (schemaVersion === LEGACY_STORE_SCHEMA_VERSION
|
|
715
|
+
&& record.state === "pending"
|
|
716
|
+
&& record.serverContactRequestId != null) {
|
|
717
|
+
record.decisionRecoveryRequired = true;
|
|
718
|
+
}
|
|
719
|
+
if (record.decisionRecoveryRequired
|
|
720
|
+
&& (record.state !== "pending"
|
|
721
|
+
|| record.serverContactRequestId == null
|
|
722
|
+
|| record.decisionAttempt != null)) {
|
|
723
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_invalid");
|
|
724
|
+
}
|
|
725
|
+
// Local wall-clock timestamps are observations, not an ordering guarantee:
|
|
726
|
+
// automatic clock correction must not make durable requests unreadable.
|
|
727
|
+
// State transitions, immutable digests and replay IDs remain authoritative.
|
|
728
|
+
const expectedDigest = digestImmutableRecord(record);
|
|
729
|
+
if (!constantTimeDigestEqual(expectedDigest, record.localRecordDigest)) {
|
|
730
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_digest_invalid");
|
|
731
|
+
}
|
|
732
|
+
return record;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function digestImmutableRecord(record) {
|
|
736
|
+
const immutable = {
|
|
737
|
+
localRecordId: record.localRecordId,
|
|
738
|
+
...Object.fromEntries(INCOMING_KEYS.map((key) => [key, record[key]])),
|
|
739
|
+
receivedAt: record.receivedAt,
|
|
740
|
+
};
|
|
741
|
+
return crypto.createHash("sha256")
|
|
742
|
+
.update(`${LOCAL_RECORD_DIGEST_DOMAIN}${JSON.stringify(immutable)}`, "utf8")
|
|
743
|
+
.digest("hex");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function acknowledgementReceipt(record, duplicate) {
|
|
747
|
+
return Object.freeze({
|
|
748
|
+
contractId: LOCAL_CONTACT_REQUEST_STORE_CONTRACT,
|
|
749
|
+
ackState: "persisted_local",
|
|
750
|
+
localRecordId: record.localRecordId,
|
|
751
|
+
localRecordDigest: record.localRecordDigest,
|
|
752
|
+
attemptId: record.attemptId,
|
|
753
|
+
eventId: record.eventId,
|
|
754
|
+
clientRequestId: record.clientRequestId,
|
|
755
|
+
connectionId: record.connectionId,
|
|
756
|
+
recipientPrincipalId: record.recipientPrincipalId,
|
|
757
|
+
recipientEndpointId: record.recipientDeviceId,
|
|
758
|
+
replayKey: record.replayKey,
|
|
759
|
+
envelopeDigest: record.envelopeDigest,
|
|
760
|
+
claimId: record.claimId,
|
|
761
|
+
ackDeadlineAt: record.ackDeadlineAt,
|
|
762
|
+
duplicate,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function projectionReceipt(record, duplicate) {
|
|
767
|
+
return Object.freeze({
|
|
768
|
+
contractId: LOCAL_CONTACT_REQUEST_STORE_CONTRACT,
|
|
769
|
+
localRecordId: record.localRecordId,
|
|
770
|
+
localRecordDigest: record.localRecordDigest,
|
|
771
|
+
serverContactRequestId: record.serverContactRequestId,
|
|
772
|
+
state: record.state,
|
|
773
|
+
duplicate,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function decisionAttemptReceipt(record) {
|
|
778
|
+
return Object.freeze({
|
|
779
|
+
contractId: LOCAL_CONTACT_REQUEST_STORE_CONTRACT,
|
|
780
|
+
localRecordId: record.localRecordId,
|
|
781
|
+
localRecordDigest: record.localRecordDigest,
|
|
782
|
+
serverContactRequestId: record.serverContactRequestId,
|
|
783
|
+
state: record.state,
|
|
784
|
+
decisionId: record.decisionAttempt?.decisionId ?? null,
|
|
785
|
+
action: record.decisionAttempt?.action ?? null,
|
|
786
|
+
startedAt: record.decisionAttempt?.startedAt ?? null,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function unresolvedDecisionProjection(record) {
|
|
791
|
+
return Object.freeze({
|
|
792
|
+
localRecordId: record.localRecordId,
|
|
793
|
+
localRecordDigest: record.localRecordDigest,
|
|
794
|
+
serverContactRequestId: record.serverContactRequestId,
|
|
795
|
+
state: record.state,
|
|
796
|
+
decisionId: record.decisionAttempt?.decisionId ?? null,
|
|
797
|
+
action: record.decisionAttempt?.action ?? null,
|
|
798
|
+
startedAt: record.decisionAttempt?.startedAt ?? null,
|
|
799
|
+
recoveryRequired: record.decisionRecoveryRequired,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function humanProjection(record) {
|
|
804
|
+
return Object.freeze({
|
|
805
|
+
humanOnly: true,
|
|
806
|
+
modelVisibility: "none",
|
|
807
|
+
localRecordId: record.localRecordId,
|
|
808
|
+
localRecordDigest: record.localRecordDigest,
|
|
809
|
+
serverContactRequestId: record.serverContactRequestId,
|
|
810
|
+
state: record.state,
|
|
811
|
+
actionReady: record.serverContactRequestId != null
|
|
812
|
+
&& record.state === "pending"
|
|
813
|
+
&& record.decisionAttempt == null
|
|
814
|
+
&& !record.decisionRecoveryRequired,
|
|
815
|
+
decisionOutcome: record.decisionRecoveryRequired
|
|
816
|
+
? "recovery-required"
|
|
817
|
+
: record.decisionAttempt == null ? null : "unknown",
|
|
818
|
+
decisionAction: record.decisionAttempt?.action ?? null,
|
|
819
|
+
introText: record.introText,
|
|
820
|
+
introWeight: record.introWeight,
|
|
821
|
+
sender: Object.freeze({
|
|
822
|
+
principalId: record.senderPrincipalId,
|
|
823
|
+
hellolynkId: record.senderHelloLynkId,
|
|
824
|
+
verifiedDisplayName: record.senderPrincipalDisplayName,
|
|
825
|
+
deviceId: record.senderDeviceId,
|
|
826
|
+
identityKeyId: record.senderIdentityKeyId,
|
|
827
|
+
}),
|
|
828
|
+
recipient: Object.freeze({
|
|
829
|
+
principalId: record.recipientPrincipalId,
|
|
830
|
+
hellolynkId: record.recipientHelloLynkId,
|
|
831
|
+
deviceId: record.recipientDeviceId,
|
|
832
|
+
identityKeyId: record.recipientIdentityKeyId,
|
|
833
|
+
}),
|
|
834
|
+
receivedAt: record.receivedAt,
|
|
835
|
+
serverUpdatedAt: record.serverUpdatedAt,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function normalizeProjection(input) {
|
|
840
|
+
assertExactFields(input, PROJECTION_KEYS, "contact_request_local_projection_invalid");
|
|
841
|
+
return Object.freeze({
|
|
842
|
+
localRecordId: normalizeLocalRecordId(input.localRecordId),
|
|
843
|
+
expectedLocalRecordDigest: normalizeDigest(
|
|
844
|
+
input.expectedLocalRecordDigest,
|
|
845
|
+
"contact_request_local_record_digest_invalid",
|
|
846
|
+
),
|
|
847
|
+
serverContactRequestId: normalizeId(
|
|
848
|
+
input.serverContactRequestId,
|
|
849
|
+
"contact_request_server_request_id_invalid",
|
|
850
|
+
),
|
|
851
|
+
state: normalizeProjectionState(input.state),
|
|
852
|
+
serverUpdatedAt: normalizeTimestamp(
|
|
853
|
+
input.serverUpdatedAt,
|
|
854
|
+
"contact_request_server_updated_at_invalid",
|
|
855
|
+
),
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function normalizeBeginDecision(input) {
|
|
860
|
+
assertExactFields(input, BEGIN_DECISION_KEYS, "contact_request_local_decision_input_invalid");
|
|
861
|
+
return Object.freeze({
|
|
862
|
+
...normalizeDecisionRecordIdentity(input),
|
|
863
|
+
action: normalizeDecisionAction(input.action),
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function normalizeAbortDecision(input) {
|
|
868
|
+
assertExactFields(input, ABORT_DECISION_KEYS, "contact_request_local_decision_input_invalid");
|
|
869
|
+
return Object.freeze({
|
|
870
|
+
...normalizeDecisionRecordIdentity(input),
|
|
871
|
+
decisionId: normalizeDecisionId(input.decisionId, false),
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function normalizeRecoverDecision(input) {
|
|
876
|
+
assertExactFields(input, RECOVER_DECISION_KEYS, "contact_request_local_decision_input_invalid");
|
|
877
|
+
return Object.freeze({
|
|
878
|
+
...normalizeDecisionRecordIdentity(input),
|
|
879
|
+
decisionId: normalizeDecisionId(input.decisionId, true),
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function normalizeResolveDecision(input) {
|
|
884
|
+
assertExactFields(input, RESOLVE_DECISION_KEYS, "contact_request_local_decision_input_invalid");
|
|
885
|
+
const state = normalizeProjectionState(input.state);
|
|
886
|
+
if (!FINAL_PROJECTION_STATES.has(state)) {
|
|
887
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_decision_state_invalid");
|
|
888
|
+
}
|
|
889
|
+
return Object.freeze({
|
|
890
|
+
...normalizeDecisionRecordIdentity(input),
|
|
891
|
+
decisionId: normalizeDecisionId(input.decisionId, false),
|
|
892
|
+
state,
|
|
893
|
+
serverUpdatedAt: normalizeTimestamp(
|
|
894
|
+
input.serverUpdatedAt,
|
|
895
|
+
"contact_request_server_updated_at_invalid",
|
|
896
|
+
),
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function normalizeDecisionRecordIdentity(input) {
|
|
901
|
+
return {
|
|
902
|
+
localRecordId: normalizeLocalRecordId(input.localRecordId),
|
|
903
|
+
expectedLocalRecordDigest: normalizeDigest(
|
|
904
|
+
input.expectedLocalRecordDigest,
|
|
905
|
+
"contact_request_local_record_digest_invalid",
|
|
906
|
+
),
|
|
907
|
+
serverContactRequestId: normalizeId(
|
|
908
|
+
input.serverContactRequestId,
|
|
909
|
+
"contact_request_server_request_id_invalid",
|
|
910
|
+
),
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function requireDecisionRecord(store, accepted) {
|
|
915
|
+
const record = store.records.find((item) => item.localRecordId === accepted.localRecordId);
|
|
916
|
+
if (!record) {
|
|
917
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_record_not_found", {
|
|
918
|
+
status: 404,
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
if (record.localRecordDigest !== accepted.expectedLocalRecordDigest) {
|
|
922
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_record_digest_mismatch", {
|
|
923
|
+
status: 409,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
if (record.serverContactRequestId !== accepted.serverContactRequestId) {
|
|
927
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_server_id_conflict", {
|
|
928
|
+
status: 409,
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
return record;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function assertDecisionCas(record, decisionId) {
|
|
935
|
+
if (record.decisionAttempt == null || record.decisionAttempt.decisionId !== decisionId) {
|
|
936
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_decision_cas_conflict", {
|
|
937
|
+
status: 409,
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function normalizeHumanStates(value) {
|
|
943
|
+
if (value == null) return new Set(PROJECTION_STATES);
|
|
944
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > PROJECTION_STATES.size) {
|
|
945
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_human_states_invalid");
|
|
946
|
+
}
|
|
947
|
+
const states = new Set(value.map(normalizeProjectionState));
|
|
948
|
+
if (states.size !== value.length) {
|
|
949
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_human_states_invalid");
|
|
950
|
+
}
|
|
951
|
+
return states;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function normalizeHumanLimit(value, maximumRecords) {
|
|
955
|
+
if (value == null) return maximumRecords;
|
|
956
|
+
const limit = Number(value);
|
|
957
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximumRecords) {
|
|
958
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_human_limit_invalid");
|
|
959
|
+
}
|
|
960
|
+
return limit;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function normalizeProjectionState(value) {
|
|
964
|
+
const state = String(value || "");
|
|
965
|
+
if (!PROJECTION_STATES.has(state)) {
|
|
966
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_state_invalid");
|
|
967
|
+
}
|
|
968
|
+
return state;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function normalizeDecisionAttempt(value) {
|
|
972
|
+
if (value == null) return null;
|
|
973
|
+
assertExactFields(value, DECISION_ATTEMPT_KEYS, "contact_request_local_decision_attempt_invalid");
|
|
974
|
+
return Object.freeze({
|
|
975
|
+
decisionId: normalizeDecisionId(value.decisionId, false),
|
|
976
|
+
action: normalizeDecisionAction(value.action),
|
|
977
|
+
startedAt: normalizeTimestamp(
|
|
978
|
+
value.startedAt,
|
|
979
|
+
"contact_request_local_decision_started_at_invalid",
|
|
980
|
+
),
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function normalizeDecisionAction(value) {
|
|
985
|
+
const action = String(value || "");
|
|
986
|
+
if (!DECISION_ACTIONS.has(action)) {
|
|
987
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_decision_action_invalid");
|
|
988
|
+
}
|
|
989
|
+
return action;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function normalizeDecisionId(value, nullable) {
|
|
993
|
+
if (nullable && value == null) return null;
|
|
994
|
+
return normalizeId(value, "contact_request_local_decision_id_invalid");
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function normalizeStoredBoolean(value, code) {
|
|
998
|
+
if (typeof value !== "boolean") {
|
|
999
|
+
throw new HelloLynkLocalContactRequestsError(code);
|
|
1000
|
+
}
|
|
1001
|
+
return value;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function normalizeContactRequestDataProtection(protector, profileId) {
|
|
1005
|
+
if (protector == null) return null;
|
|
1006
|
+
if (["sealJson", "openJson"].some((method) => typeof protector?.[method] !== "function")) {
|
|
1007
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_data_protector_invalid");
|
|
1008
|
+
}
|
|
1009
|
+
return Object.freeze({
|
|
1010
|
+
protector,
|
|
1011
|
+
context: Object.freeze({
|
|
1012
|
+
profileId,
|
|
1013
|
+
assetKind: "contact-requests",
|
|
1014
|
+
logicalPath: `contact-requests/${profileId}.json`,
|
|
1015
|
+
}),
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async function readStore(layout, profileId, maximumBytes, maximumRecords, protection = null) {
|
|
1020
|
+
await assertContactRequestStoreAccess(layout, protection);
|
|
1021
|
+
const layoutExists = await assertSafeStoreLayout(layout, { create: false });
|
|
1022
|
+
if (!layoutExists) return emptyStore(profileId);
|
|
1023
|
+
const physicalMaximum = protectedStoreMaximum(maximumBytes, protection);
|
|
1024
|
+
let metadata;
|
|
1025
|
+
try {
|
|
1026
|
+
metadata = await lstat(layout.storePath);
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
if (error?.code === "ENOENT") return emptyStore(profileId);
|
|
1029
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_read_failed", {
|
|
1030
|
+
cause: error,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > physicalMaximum) {
|
|
1034
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_invalid");
|
|
1035
|
+
}
|
|
1036
|
+
let handle;
|
|
1037
|
+
let parsed;
|
|
1038
|
+
try {
|
|
1039
|
+
const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
|
|
1040
|
+
handle = await open(layout.storePath, fsConstants.O_RDONLY | noFollow);
|
|
1041
|
+
const openedMetadata = await handle.stat();
|
|
1042
|
+
const windowsUnreportedDevice = process.platform === "win32"
|
|
1043
|
+
&& (metadata.dev === 0 || openedMetadata.dev === 0);
|
|
1044
|
+
if (
|
|
1045
|
+
!openedMetadata.isFile()
|
|
1046
|
+
|| openedMetadata.size > physicalMaximum
|
|
1047
|
+
|| (windowsUnreportedDevice && metadata.ino !== openedMetadata.ino)
|
|
1048
|
+
|| (
|
|
1049
|
+
Number.isSafeInteger(metadata.dev)
|
|
1050
|
+
&& Number.isSafeInteger(metadata.ino)
|
|
1051
|
+
&& Number.isSafeInteger(openedMetadata.dev)
|
|
1052
|
+
&& Number.isSafeInteger(openedMetadata.ino)
|
|
1053
|
+
&& (metadata.ino !== openedMetadata.ino
|
|
1054
|
+
|| (metadata.dev !== openedMetadata.dev
|
|
1055
|
+
&& !windowsUnreportedDevice))
|
|
1056
|
+
)
|
|
1057
|
+
) {
|
|
1058
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_invalid");
|
|
1059
|
+
}
|
|
1060
|
+
const stored = JSON.parse(await handle.readFile("utf8"));
|
|
1061
|
+
parsed = protection
|
|
1062
|
+
? await protection.protector.openJson(stored, protection.context)
|
|
1063
|
+
: stored;
|
|
1064
|
+
if (serializedStoreBytes(parsed) > maximumBytes) {
|
|
1065
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_invalid");
|
|
1066
|
+
}
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
if (error instanceof HelloLynkLocalContactRequestsError) throw error;
|
|
1069
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_invalid", { cause: error });
|
|
1070
|
+
} finally {
|
|
1071
|
+
await handle?.close().catch(() => {});
|
|
1072
|
+
}
|
|
1073
|
+
assertExactFields(parsed, STORE_KEYS, "contact_request_local_store_invalid");
|
|
1074
|
+
if (
|
|
1075
|
+
!new Set([LEGACY_STORE_SCHEMA_VERSION, STORE_SCHEMA_VERSION]).has(parsed.schemaVersion)
|
|
1076
|
+
|| parsed.contractId !== LOCAL_CONTACT_REQUEST_STORE_CONTRACT
|
|
1077
|
+
|| parsed.profileId !== profileId
|
|
1078
|
+
|| !Array.isArray(parsed.records)
|
|
1079
|
+
|| parsed.records.length > maximumRecords
|
|
1080
|
+
) {
|
|
1081
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_invalid");
|
|
1082
|
+
}
|
|
1083
|
+
const records = parsed.records.map((record) => normalizeStoredRecord(
|
|
1084
|
+
record,
|
|
1085
|
+
parsed.schemaVersion,
|
|
1086
|
+
));
|
|
1087
|
+
assertUniqueRecords(records);
|
|
1088
|
+
await applyBestEffortPrivateMode(layout.storePath);
|
|
1089
|
+
return { ...emptyStore(profileId), records };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function assertUniqueRecords(records) {
|
|
1093
|
+
for (const key of ["localRecordId", "replayKey", "eventId", "attemptId"]) {
|
|
1094
|
+
const seen = new Set();
|
|
1095
|
+
for (const record of records) {
|
|
1096
|
+
if (seen.has(record[key])) {
|
|
1097
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_invalid");
|
|
1098
|
+
}
|
|
1099
|
+
seen.add(record[key]);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
const senderClientPairs = new Set();
|
|
1103
|
+
for (const record of records) {
|
|
1104
|
+
const pair = `${record.senderPrincipalId}\0${record.clientRequestId}`;
|
|
1105
|
+
if (senderClientPairs.has(pair)) {
|
|
1106
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_invalid");
|
|
1107
|
+
}
|
|
1108
|
+
senderClientPairs.add(pair);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
async function atomicWriteStore(layout, store, maximumBytes, protection = null) {
|
|
1113
|
+
await assertContactRequestStoreAccess(layout, protection);
|
|
1114
|
+
await assertSafeStoreLayout(layout, { create: true });
|
|
1115
|
+
try {
|
|
1116
|
+
const current = await lstat(layout.storePath);
|
|
1117
|
+
if (current.isSymbolicLink() || !current.isFile()) {
|
|
1118
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_invalid");
|
|
1119
|
+
}
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
if (error?.code !== "ENOENT") throw error;
|
|
1122
|
+
}
|
|
1123
|
+
if (serializedStoreBytes(store) > maximumBytes) {
|
|
1124
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_too_large", {
|
|
1125
|
+
status: 507,
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
const stored = protection
|
|
1129
|
+
? await protection.protector.sealJson(store, protection.context)
|
|
1130
|
+
: store;
|
|
1131
|
+
const body = `${JSON.stringify(stored, null, 2)}\n`;
|
|
1132
|
+
if (Buffer.byteLength(body, "utf8") > protectedStoreMaximum(maximumBytes, protection)) {
|
|
1133
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_too_large", {
|
|
1134
|
+
status: 507,
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
const temporary = `${layout.storePath}.${crypto.randomUUID()}.tmp`;
|
|
1138
|
+
let handle;
|
|
1139
|
+
try {
|
|
1140
|
+
handle = await open(temporary, "wx", 0o600);
|
|
1141
|
+
await handle.writeFile(body, "utf8");
|
|
1142
|
+
await handle.sync();
|
|
1143
|
+
} catch (error) {
|
|
1144
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
1145
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_write_failed", {
|
|
1146
|
+
cause: error,
|
|
1147
|
+
});
|
|
1148
|
+
} finally {
|
|
1149
|
+
await handle?.close().catch(() => {});
|
|
1150
|
+
}
|
|
1151
|
+
try {
|
|
1152
|
+
await assertContactRequestStoreAccess(layout, protection);
|
|
1153
|
+
await assertSafeStoreLayout(layout, { create: false });
|
|
1154
|
+
await replaceFileWithRetry(temporary, layout.storePath);
|
|
1155
|
+
await applyBestEffortPrivateMode(layout.storePath);
|
|
1156
|
+
await syncDirectory(layout.directoryPath);
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
1159
|
+
if (error instanceof HelloLynkLocalContactRequestsError) throw error;
|
|
1160
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_write_failed", {
|
|
1161
|
+
cause: error,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
async function assertContactRequestStoreAccess(layout, protection) {
|
|
1167
|
+
try {
|
|
1168
|
+
await assertLocalDataProtectionAccess(layout.rootPath, protection != null);
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
if (error instanceof HelloLynkLocalContactRequestsError) throw error;
|
|
1171
|
+
throw new HelloLynkLocalContactRequestsError(
|
|
1172
|
+
error?.code || "contact_request_local_store_access_invalid",
|
|
1173
|
+
{ cause: error },
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function serializedStoreBytes(store) {
|
|
1179
|
+
return Buffer.byteLength(`${JSON.stringify(store, null, 2)}\n`, "utf8");
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function protectedStoreMaximum(maximumBytes, protection) {
|
|
1183
|
+
return protection ? maximumBytes * 2 : maximumBytes;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async function replaceFileWithRetry(sourcePath, targetPath) {
|
|
1187
|
+
let lastError;
|
|
1188
|
+
for (const wait of [0, 10, 25, 75, 200, 400]) {
|
|
1189
|
+
if (wait > 0) await delay(wait);
|
|
1190
|
+
try {
|
|
1191
|
+
await rename(sourcePath, targetPath);
|
|
1192
|
+
return;
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
lastError = error;
|
|
1195
|
+
if (!["EACCES", "EPERM", "EBUSY"].includes(error?.code)) throw error;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
throw lastError;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async function syncDirectory(directoryPath) {
|
|
1202
|
+
let handle;
|
|
1203
|
+
try {
|
|
1204
|
+
handle = await open(directoryPath, "r");
|
|
1205
|
+
await handle.sync();
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
if (!["EBADF", "EINVAL", "EISDIR", "ENOTSUP", "EPERM"].includes(error?.code)) {
|
|
1208
|
+
throw error;
|
|
1209
|
+
}
|
|
1210
|
+
} finally {
|
|
1211
|
+
await handle?.close().catch(() => {});
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async function applyBestEffortPrivateMode(filePath) {
|
|
1216
|
+
try {
|
|
1217
|
+
await chmod(filePath, 0o600);
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
if (!["EACCES", "EPERM", "ENOTSUP", "EINVAL"].includes(error?.code)) throw error;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
async function withPathMutationQueue(storePath, callback) {
|
|
1224
|
+
const previous = pathMutationQueues.get(storePath) || Promise.resolve();
|
|
1225
|
+
let release;
|
|
1226
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
1227
|
+
const tail = previous.then(() => gate);
|
|
1228
|
+
pathMutationQueues.set(storePath, tail);
|
|
1229
|
+
await previous;
|
|
1230
|
+
try {
|
|
1231
|
+
return await withCrossProcessLock(storePath, callback);
|
|
1232
|
+
} finally {
|
|
1233
|
+
release();
|
|
1234
|
+
if (pathMutationQueues.get(storePath) === tail) pathMutationQueues.delete(storePath);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
async function withCrossProcessLock(storePath, callback) {
|
|
1239
|
+
const layout = resolveStoreLayoutFromPath(storePath);
|
|
1240
|
+
await assertSafeStoreLayout(layout, { create: true });
|
|
1241
|
+
const lockPath = `${storePath}.lock`;
|
|
1242
|
+
const recoveryPath = `${lockPath}.recovery`;
|
|
1243
|
+
let ownershipToken = null;
|
|
1244
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
|
|
1245
|
+
if (await pathExists(recoveryPath)) {
|
|
1246
|
+
await removeDeadOwnedLock(recoveryPath);
|
|
1247
|
+
await delay(LOCK_WAIT_MS);
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
try {
|
|
1251
|
+
({ ownershipToken } = await publishOwnedLock(lockPath));
|
|
1252
|
+
break;
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
ownershipToken = null;
|
|
1255
|
+
if (error?.code !== "EEXIST") {
|
|
1256
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_lock_failed", {
|
|
1257
|
+
cause: error,
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
await recoverDeadLock(lockPath);
|
|
1261
|
+
await delay(LOCK_WAIT_MS);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (!ownershipToken) {
|
|
1265
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_busy", {
|
|
1266
|
+
status: 503,
|
|
1267
|
+
retryable: true,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
try {
|
|
1271
|
+
return await callback();
|
|
1272
|
+
} finally {
|
|
1273
|
+
await releaseOwnedLock(lockPath, null, ownershipToken);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
async function recoverDeadLock(lockPath) {
|
|
1278
|
+
const recoveryPath = `${lockPath}.recovery`;
|
|
1279
|
+
let recoveryToken = null;
|
|
1280
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1281
|
+
try {
|
|
1282
|
+
({ ownershipToken: recoveryToken } = await publishOwnedLock(recoveryPath));
|
|
1283
|
+
break;
|
|
1284
|
+
} catch (error) {
|
|
1285
|
+
recoveryToken = null;
|
|
1286
|
+
if (error?.code !== "EEXIST") throw error;
|
|
1287
|
+
if (!(await removeDeadOwnedLock(recoveryPath))) return false;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
if (!recoveryToken) return false;
|
|
1291
|
+
try {
|
|
1292
|
+
return await removeDeadOwnedLock(lockPath);
|
|
1293
|
+
} finally {
|
|
1294
|
+
await releaseOwnedLock(recoveryPath, null, recoveryToken);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
async function publishOwnedLock(lockPath) {
|
|
1299
|
+
const ownershipToken = crypto.randomBytes(24).toString("hex");
|
|
1300
|
+
const candidatePath = `${lockPath}.candidate.${process.pid}.${crypto.randomUUID()}`;
|
|
1301
|
+
let candidateHandle = null;
|
|
1302
|
+
let published = false;
|
|
1303
|
+
let failure = null;
|
|
1304
|
+
try {
|
|
1305
|
+
candidateHandle = await retryWindowsTransientFs(() => open(candidatePath, "wx", 0o600));
|
|
1306
|
+
await candidateHandle.writeFile(`${JSON.stringify({
|
|
1307
|
+
pid: process.pid,
|
|
1308
|
+
ownershipToken,
|
|
1309
|
+
createdAt: Date.now(),
|
|
1310
|
+
})}\n`, "utf8");
|
|
1311
|
+
await candidateHandle.sync();
|
|
1312
|
+
await candidateHandle.close();
|
|
1313
|
+
candidateHandle = null;
|
|
1314
|
+
|
|
1315
|
+
// Publish a fully written inode without ever exposing an empty/partial
|
|
1316
|
+
// authoritative lock file. link() is atomic and refuses to overwrite an
|
|
1317
|
+
// existing owner, so a paused creator cannot later enter the critical
|
|
1318
|
+
// section after another process has acquired the same lock.
|
|
1319
|
+
for (let attempt = 0; attempt < WINDOWS_TRANSIENT_FS_RETRY_MS.length; attempt += 1) {
|
|
1320
|
+
const wait = WINDOWS_TRANSIENT_FS_RETRY_MS[attempt];
|
|
1321
|
+
if (wait > 0) await delay(wait);
|
|
1322
|
+
try {
|
|
1323
|
+
await link(candidatePath, lockPath);
|
|
1324
|
+
published = true;
|
|
1325
|
+
break;
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
if (error?.code === "EEXIST") throw error;
|
|
1328
|
+
if (!isWindowsTransientFsError(error)) throw error;
|
|
1329
|
+
if (await pathExists(lockPath)) throw lockAlreadyOwnedError(error);
|
|
1330
|
+
if (attempt === WINDOWS_TRANSIENT_FS_RETRY_MS.length - 1) throw error;
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (!published) throw new Error("contact_request_local_lock_publish_failed");
|
|
1334
|
+
} catch (error) {
|
|
1335
|
+
failure = error;
|
|
1336
|
+
} finally {
|
|
1337
|
+
await candidateHandle?.close().catch(() => {});
|
|
1338
|
+
try {
|
|
1339
|
+
await removeWithWindowsRetry(candidatePath);
|
|
1340
|
+
} catch (error) {
|
|
1341
|
+
failure ||= error;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if (failure) {
|
|
1345
|
+
if (published) await releaseOwnedLock(lockPath, null, ownershipToken).catch(() => {});
|
|
1346
|
+
throw failure;
|
|
1347
|
+
}
|
|
1348
|
+
return { ownershipToken };
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
async function removeDeadOwnedLock(lockPath) {
|
|
1352
|
+
const first = await readLockSnapshot(lockPath);
|
|
1353
|
+
if (!first || !lockSnapshotEligibleForRecovery(first)) return false;
|
|
1354
|
+
await delay(LOCK_WAIT_MS);
|
|
1355
|
+
const second = await readLockSnapshot(lockPath);
|
|
1356
|
+
if (
|
|
1357
|
+
!second
|
|
1358
|
+
|| !lockSnapshotEligibleForRecovery(second)
|
|
1359
|
+
|| !sameLockSnapshot(first, second)
|
|
1360
|
+
) return false;
|
|
1361
|
+
const stalePath = `${lockPath}.stale.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
|
|
1362
|
+
try {
|
|
1363
|
+
await rename(lockPath, stalePath);
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
if (["ENOENT", "EACCES", "EPERM", "EBUSY"].includes(error?.code)) return false;
|
|
1366
|
+
throw error;
|
|
1367
|
+
}
|
|
1368
|
+
const quarantined = await readLockSnapshot(stalePath);
|
|
1369
|
+
if (!quarantined || !sameLockSnapshot(second, quarantined)) {
|
|
1370
|
+
try {
|
|
1371
|
+
await rename(stalePath, lockPath);
|
|
1372
|
+
} catch {}
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1375
|
+
await rm(stalePath, { force: true });
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
async function readLockSnapshot(lockPath) {
|
|
1380
|
+
let metadata;
|
|
1381
|
+
try {
|
|
1382
|
+
metadata = await retryWindowsTransientFs(() => lstat(lockPath));
|
|
1383
|
+
} catch (error) {
|
|
1384
|
+
if (error?.code === "ENOENT") return null;
|
|
1385
|
+
throw error;
|
|
1386
|
+
}
|
|
1387
|
+
if (metadata.isSymbolicLink() || !metadata.isFile() || metadata.size > MAX_LOCK_BYTES) return null;
|
|
1388
|
+
let body;
|
|
1389
|
+
try {
|
|
1390
|
+
body = await retryWindowsTransientFs(() => readFile(lockPath));
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
if (error?.code === "ENOENT") return null;
|
|
1393
|
+
throw error;
|
|
1394
|
+
}
|
|
1395
|
+
if (body.length !== metadata.size) return null;
|
|
1396
|
+
return { metadata, body };
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function lockSnapshotEligibleForRecovery(snapshot) {
|
|
1400
|
+
if (Date.now() - snapshot.metadata.mtimeMs < LOCK_STALE_MS) return false;
|
|
1401
|
+
let record = null;
|
|
1402
|
+
try {
|
|
1403
|
+
record = JSON.parse(snapshot.body.toString("utf8"));
|
|
1404
|
+
} catch {}
|
|
1405
|
+
const pid = Number(record?.pid);
|
|
1406
|
+
if (
|
|
1407
|
+
Number.isSafeInteger(pid)
|
|
1408
|
+
&& pid > 0
|
|
1409
|
+
&& typeof record?.ownershipToken === "string"
|
|
1410
|
+
&& record.ownershipToken.length >= 16
|
|
1411
|
+
&& Number.isFinite(Number(record?.createdAt))
|
|
1412
|
+
) return !processAlive(pid);
|
|
1413
|
+
return true;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function sameLockSnapshot(left, right) {
|
|
1417
|
+
const leftMetadata = left.metadata;
|
|
1418
|
+
const rightMetadata = right.metadata;
|
|
1419
|
+
const identitiesComparable = (
|
|
1420
|
+
Number.isSafeInteger(leftMetadata.dev)
|
|
1421
|
+
&& Number.isSafeInteger(leftMetadata.ino)
|
|
1422
|
+
&& Number.isSafeInteger(rightMetadata.dev)
|
|
1423
|
+
&& Number.isSafeInteger(rightMetadata.ino)
|
|
1424
|
+
);
|
|
1425
|
+
return (
|
|
1426
|
+
(!identitiesComparable || (
|
|
1427
|
+
leftMetadata.dev === rightMetadata.dev
|
|
1428
|
+
&& leftMetadata.ino === rightMetadata.ino
|
|
1429
|
+
))
|
|
1430
|
+
&& leftMetadata.size === rightMetadata.size
|
|
1431
|
+
&& leftMetadata.mtimeMs === rightMetadata.mtimeMs
|
|
1432
|
+
&& left.body.length === right.body.length
|
|
1433
|
+
&& crypto.timingSafeEqual(left.body, right.body)
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
async function pathExists(candidatePath) {
|
|
1438
|
+
try {
|
|
1439
|
+
await retryWindowsTransientFs(() => lstat(candidatePath));
|
|
1440
|
+
return true;
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
if (error?.code === "ENOENT") return false;
|
|
1443
|
+
throw error;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
async function releaseOwnedLock(lockPath, handle, ownershipToken) {
|
|
1448
|
+
await handle?.close().catch(() => {});
|
|
1449
|
+
try {
|
|
1450
|
+
const current = JSON.parse(await retryWindowsTransientFs(() => readFile(lockPath, "utf8")));
|
|
1451
|
+
if (current?.ownershipToken === ownershipToken) await removeWithWindowsRetry(lockPath);
|
|
1452
|
+
} catch (error) {
|
|
1453
|
+
if (error?.code === "ENOENT" || error instanceof SyntaxError) return;
|
|
1454
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_lock_failed", {
|
|
1455
|
+
cause: error,
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
async function removeWithWindowsRetry(filePath) {
|
|
1461
|
+
return retryWindowsTransientFs(async () => {
|
|
1462
|
+
try {
|
|
1463
|
+
await rm(filePath, { force: true });
|
|
1464
|
+
} catch (error) {
|
|
1465
|
+
if (error?.code === "ENOENT") return;
|
|
1466
|
+
throw error;
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
async function retryWindowsTransientFs(operation) {
|
|
1472
|
+
let lastError = null;
|
|
1473
|
+
for (const wait of WINDOWS_TRANSIENT_FS_RETRY_MS) {
|
|
1474
|
+
if (wait > 0) await delay(wait);
|
|
1475
|
+
try {
|
|
1476
|
+
return await operation();
|
|
1477
|
+
} catch (error) {
|
|
1478
|
+
lastError = error;
|
|
1479
|
+
if (!isWindowsTransientFsError(error)) throw error;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
throw lastError;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function isWindowsTransientFsError(error) {
|
|
1486
|
+
return process.platform === "win32" && WINDOWS_TRANSIENT_FS_CODES.has(error?.code);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
function lockAlreadyOwnedError(cause) {
|
|
1490
|
+
const error = new Error("contact_request_local_lock_exists", { cause });
|
|
1491
|
+
error.code = "EEXIST";
|
|
1492
|
+
return error;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function processAlive(pid) {
|
|
1496
|
+
try {
|
|
1497
|
+
process.kill(pid, 0);
|
|
1498
|
+
return true;
|
|
1499
|
+
} catch (error) {
|
|
1500
|
+
return error?.code === "EPERM";
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function resolveStoreLayout(storePath, profileId) {
|
|
1505
|
+
const resolved = path.resolve(storePath);
|
|
1506
|
+
const directoryPath = path.dirname(resolved);
|
|
1507
|
+
const rootPath = path.dirname(directoryPath);
|
|
1508
|
+
if (
|
|
1509
|
+
path.basename(directoryPath) !== STORE_DIRECTORY_NAME
|
|
1510
|
+
|| path.basename(resolved) !== `${profileId}.json`
|
|
1511
|
+
|| rootPath === directoryPath
|
|
1512
|
+
) {
|
|
1513
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_path_invalid");
|
|
1514
|
+
}
|
|
1515
|
+
return { rootPath, directoryPath, storePath: resolved };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function resolveStoreLayoutFromPath(storePath) {
|
|
1519
|
+
const resolved = path.resolve(storePath);
|
|
1520
|
+
const profileId = path.basename(resolved, ".json");
|
|
1521
|
+
return resolveStoreLayout(resolved, profileId);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
async function assertSafeStoreLayout(layout, options = {}) {
|
|
1525
|
+
const create = options.create === true;
|
|
1526
|
+
await assertNoSymlinkAncestors(layout.rootPath);
|
|
1527
|
+
const rootExists = await assertSafeDirectory(layout.rootPath, {
|
|
1528
|
+
create,
|
|
1529
|
+
recursive: true,
|
|
1530
|
+
});
|
|
1531
|
+
if (!rootExists) return false;
|
|
1532
|
+
await assertNoSymlinkAncestors(layout.rootPath);
|
|
1533
|
+
const directoryExists = await assertSafeDirectory(layout.directoryPath, {
|
|
1534
|
+
create,
|
|
1535
|
+
recursive: false,
|
|
1536
|
+
});
|
|
1537
|
+
return directoryExists;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
async function assertNoSymlinkAncestors(targetPath) {
|
|
1541
|
+
const resolved = path.resolve(targetPath);
|
|
1542
|
+
const root = path.parse(resolved).root;
|
|
1543
|
+
let current = root;
|
|
1544
|
+
for (const segment of path.relative(root, resolved).split(path.sep).filter(Boolean)) {
|
|
1545
|
+
current = path.join(current, segment);
|
|
1546
|
+
let metadata;
|
|
1547
|
+
try {
|
|
1548
|
+
metadata = await lstat(current);
|
|
1549
|
+
} catch (error) {
|
|
1550
|
+
if (error?.code === "ENOENT") return;
|
|
1551
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_directory_invalid", {
|
|
1552
|
+
cause: error,
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
if (metadata.isSymbolicLink()) {
|
|
1556
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_directory_invalid");
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
async function assertSafeDirectory(directoryPath, options = {}) {
|
|
1562
|
+
let metadata;
|
|
1563
|
+
try {
|
|
1564
|
+
metadata = await lstat(directoryPath);
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
if (error?.code !== "ENOENT") {
|
|
1567
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_directory_invalid", {
|
|
1568
|
+
cause: error,
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
if (!options.create) return false;
|
|
1572
|
+
try {
|
|
1573
|
+
await mkdir(directoryPath, { recursive: options.recursive === true, mode: 0o700 });
|
|
1574
|
+
metadata = await lstat(directoryPath);
|
|
1575
|
+
} catch (createError) {
|
|
1576
|
+
if (createError?.code === "EEXIST") {
|
|
1577
|
+
try {
|
|
1578
|
+
metadata = await lstat(directoryPath);
|
|
1579
|
+
} catch (raceError) {
|
|
1580
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_directory_invalid", {
|
|
1581
|
+
cause: raceError,
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
} else {
|
|
1585
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_directory_invalid", {
|
|
1586
|
+
cause: createError,
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
|
1592
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_store_directory_invalid");
|
|
1593
|
+
}
|
|
1594
|
+
return true;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function normalizeProfileId(value) {
|
|
1598
|
+
const profileId = String(value || "").trim();
|
|
1599
|
+
if (!PROFILE_ID_RE.test(profileId)) {
|
|
1600
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_profile_id_invalid");
|
|
1601
|
+
}
|
|
1602
|
+
return profileId;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function normalizeLocalRecordId(value) {
|
|
1606
|
+
const localRecordId = String(value || "");
|
|
1607
|
+
if (!LOCAL_RECORD_ID_RE.test(localRecordId)) {
|
|
1608
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_record_id_invalid");
|
|
1609
|
+
}
|
|
1610
|
+
return localRecordId;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
function normalizeId(value, code) {
|
|
1614
|
+
if (typeof value !== "string" || !SAFE_ID_RE.test(value)) {
|
|
1615
|
+
throw new HelloLynkLocalContactRequestsError(code);
|
|
1616
|
+
}
|
|
1617
|
+
return value;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function normalizeHelloLynkId(value, code) {
|
|
1621
|
+
if (typeof value !== "string" || !HELLOLYNK_ID_RE.test(value)) {
|
|
1622
|
+
throw new HelloLynkLocalContactRequestsError(code);
|
|
1623
|
+
}
|
|
1624
|
+
return value;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function normalizeDigest(value, code) {
|
|
1628
|
+
if (typeof value !== "string" || !SHA256_RE.test(value)) {
|
|
1629
|
+
throw new HelloLynkLocalContactRequestsError(code);
|
|
1630
|
+
}
|
|
1631
|
+
return value;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function normalizeDigestSalt(value) {
|
|
1635
|
+
if (typeof value !== "string" || !BASE64URL_16_RE.test(value)) {
|
|
1636
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_intro_digest_salt_invalid");
|
|
1637
|
+
}
|
|
1638
|
+
const decoded = Buffer.from(value, "base64url");
|
|
1639
|
+
if (decoded.length !== 16 || decoded.toString("base64url") !== value) {
|
|
1640
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_intro_digest_salt_invalid");
|
|
1641
|
+
}
|
|
1642
|
+
return value;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function normalizeOptionalDisplayName(value) {
|
|
1646
|
+
if (value == null) return null;
|
|
1647
|
+
if (typeof value !== "string") {
|
|
1648
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_sender_display_name_invalid");
|
|
1649
|
+
}
|
|
1650
|
+
const normalized = value.normalize("NFKC").trim().replace(/\s+/gu, " ");
|
|
1651
|
+
if (!normalized || [...normalized].length > 80 || UNSAFE_DISPLAY_RE.test(normalized)) {
|
|
1652
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_sender_display_name_invalid");
|
|
1653
|
+
}
|
|
1654
|
+
return normalized;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function normalizeTimestamp(value, code) {
|
|
1658
|
+
if (typeof value !== "string") throw new HelloLynkLocalContactRequestsError(code);
|
|
1659
|
+
const parsed = Date.parse(value);
|
|
1660
|
+
if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== value) {
|
|
1661
|
+
throw new HelloLynkLocalContactRequestsError(code);
|
|
1662
|
+
}
|
|
1663
|
+
return value;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function normalizeCurrentTimestamp(value) {
|
|
1667
|
+
const parsed = value instanceof Date ? value : new Date(value);
|
|
1668
|
+
if (!Number.isFinite(parsed.valueOf())) {
|
|
1669
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_clock_invalid");
|
|
1670
|
+
}
|
|
1671
|
+
return parsed.toISOString();
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function normalizeMaximumBytes(value) {
|
|
1675
|
+
if (value == null) return DEFAULT_MAX_STORE_BYTES;
|
|
1676
|
+
const maximum = Number(value);
|
|
1677
|
+
if (
|
|
1678
|
+
!Number.isSafeInteger(maximum)
|
|
1679
|
+
|| maximum < MIN_MAX_STORE_BYTES
|
|
1680
|
+
|| maximum > DEFAULT_MAX_STORE_BYTES
|
|
1681
|
+
) {
|
|
1682
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_max_store_bytes_invalid");
|
|
1683
|
+
}
|
|
1684
|
+
return maximum;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
function normalizeMaximumRecords(value) {
|
|
1688
|
+
if (value == null) return DEFAULT_MAX_RECORDS;
|
|
1689
|
+
const maximum = Number(value);
|
|
1690
|
+
if (!Number.isSafeInteger(maximum) || maximum < 1 || maximum > MAX_CONFIGURED_RECORDS) {
|
|
1691
|
+
throw new HelloLynkLocalContactRequestsError("contact_request_local_max_records_invalid");
|
|
1692
|
+
}
|
|
1693
|
+
return maximum;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
function constantTimeDigestEqual(left, right) {
|
|
1697
|
+
return timingSafeBufferEqual(Buffer.from(left, "hex"), Buffer.from(right, "hex"));
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function timingSafeBufferEqual(left, right) {
|
|
1701
|
+
return left.length === right.length && crypto.timingSafeEqual(left, right);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function assertPlainRecord(value, code) {
|
|
1705
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1706
|
+
throw new HelloLynkLocalContactRequestsError(code);
|
|
1707
|
+
}
|
|
1708
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1709
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1710
|
+
throw new HelloLynkLocalContactRequestsError(code);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function assertOnlyKnownFields(value, expectedFields, code) {
|
|
1715
|
+
if (Object.keys(value).some((field) => !expectedFields.includes(field))) {
|
|
1716
|
+
throw new HelloLynkLocalContactRequestsError(code);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
function assertExactFields(value, expectedFields, code) {
|
|
1721
|
+
assertPlainRecord(value, code);
|
|
1722
|
+
const actualFields = Object.keys(value);
|
|
1723
|
+
if (
|
|
1724
|
+
actualFields.length !== expectedFields.length
|
|
1725
|
+
|| expectedFields.some((field) => !Object.hasOwn(value, field))
|
|
1726
|
+
) {
|
|
1727
|
+
throw new HelloLynkLocalContactRequestsError(code);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function delay(milliseconds) {
|
|
1732
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
1733
|
+
}
|