@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -0
- package/LICENSE +92 -0
- package/README.md +86 -2
- package/bin/hellolynk-client-setup.mjs +2748 -0
- package/bin/hellolynk-mcp-config-shape.mjs +23 -0
- package/hellolynk-client-pack-manifest.json +619 -0
- package/hellolynk-client-pack-pin.json +4 -0
- package/package.json +58 -4
- package/plugins/hellolynk-codex/.app.json +3 -0
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
- package/plugins/hellolynk-codex/.mcp.json +19 -0
- package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/package.json +11 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +160 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
- package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
- package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
- package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
- package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
- package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
- package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
- package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
- package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
- package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
- package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
- package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
- package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
- package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
- package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
- package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
- package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
- package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
- package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
|
@@ -0,0 +1,1365 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
getMessageConversationBinding,
|
|
4
|
+
getOutgoingConversationIntent,
|
|
5
|
+
instanceConversationKey,
|
|
6
|
+
normalizeInstanceConversationBinding,
|
|
7
|
+
} from "./hellolynk-instance-conversation.mjs";
|
|
8
|
+
import {
|
|
9
|
+
HELLOLYNK_WORKBENCH_SCHEMA_VERSION,
|
|
10
|
+
HELLOLYNK_WORKBENCH_SNAPSHOT_CONTRACT,
|
|
11
|
+
HELLOLYNK_WORKBENCH_SNAPSHOT_SCHEMA_VERSION,
|
|
12
|
+
HELLOLYNK_WORKBENCH_MAX_QUERY_LENGTH,
|
|
13
|
+
} from "../workbench/contract.mjs";
|
|
14
|
+
|
|
15
|
+
export const HELLOLYNK_NEUTRAL_HOST_WORKBENCH_READ_MODEL_CONTRACT =
|
|
16
|
+
"hellolynk-neutral-host-workbench-read-model-v1";
|
|
17
|
+
|
|
18
|
+
const THREAD_PAGE_SIZE = 100;
|
|
19
|
+
const MESSAGE_PAGE_SIZE = 20;
|
|
20
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{1,198}$/;
|
|
21
|
+
const SAFE_REVISION = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,254}$/;
|
|
22
|
+
const HELLOLYNK_ID = /^hellolynk:(intl|cn):[a-z0-9][a-z0-9._-]{2,179}$/;
|
|
23
|
+
const UNSAFE_TEXT = /[\u0000-\u001F\u007F-\u009F\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]/u;
|
|
24
|
+
const FILTERS = new Set(["all", "unread", "archived"]);
|
|
25
|
+
const TRANSPORTS = new Set(["loopback-http", "mcp-app-rpc", "cursor-webview-rpc"]);
|
|
26
|
+
const HOST_STATES = new Set(["connecting", "online", "offline", "rebind-required", "error"]);
|
|
27
|
+
const RELATIONSHIP_METADATA_STATES = new Set(["loading", "fresh", "stale"]);
|
|
28
|
+
const CLIENT_TYPES = new Set(["codex", "cursor", "dsh", "openclaw", "trae-work", "workbuddy", "claude-code", "unknown"]);
|
|
29
|
+
const CLIENT_STATES = new Set(["connected", "disconnected", "stale"]);
|
|
30
|
+
const CONTACT_REQUEST_STATES = new Set([
|
|
31
|
+
"pending",
|
|
32
|
+
"accepted",
|
|
33
|
+
"rejected",
|
|
34
|
+
"cancelled",
|
|
35
|
+
"blocked",
|
|
36
|
+
"expired",
|
|
37
|
+
]);
|
|
38
|
+
const MANUAL_RETRYABLE_OUTBOX_STATUSES = new Set([
|
|
39
|
+
"recipient_offline",
|
|
40
|
+
"failed_retryable",
|
|
41
|
+
"failed_permanent",
|
|
42
|
+
]);
|
|
43
|
+
const MESSAGE_AUTHORIZATION_SCOPE = "relay.text.send";
|
|
44
|
+
const MESSAGE_INTENT_SOURCES = new Set([
|
|
45
|
+
"hellolynk_client_explicit_send",
|
|
46
|
+
"human_native_dialog",
|
|
47
|
+
"human_preauthorized_contact",
|
|
48
|
+
]);
|
|
49
|
+
const DEFAULT_CAPABILITIES = Object.freeze([
|
|
50
|
+
"contacts.read",
|
|
51
|
+
"messages.read",
|
|
52
|
+
"outbox.read",
|
|
53
|
+
"search.local",
|
|
54
|
+
"security.read",
|
|
55
|
+
"threads.read",
|
|
56
|
+
]);
|
|
57
|
+
const CAPABILITIES = new Set([
|
|
58
|
+
"contact.authorization.manage",
|
|
59
|
+
"contact.delete",
|
|
60
|
+
"contact.save",
|
|
61
|
+
"contact.rename",
|
|
62
|
+
"contact.instances.list",
|
|
63
|
+
"contact.instance.save",
|
|
64
|
+
"contact.instance.rename",
|
|
65
|
+
"contact.instance.delete",
|
|
66
|
+
"contact-request.clear-resolved",
|
|
67
|
+
"contact-request.recover-decision",
|
|
68
|
+
"contact-request.respond",
|
|
69
|
+
"contact-request.cancel",
|
|
70
|
+
"contact-request.send",
|
|
71
|
+
"contacts.read",
|
|
72
|
+
"messages.delete-read",
|
|
73
|
+
"messages.delete-sent",
|
|
74
|
+
"messages.mark-read",
|
|
75
|
+
"messages.mark-all-read",
|
|
76
|
+
"messages.auto-read.manage",
|
|
77
|
+
"messages.read",
|
|
78
|
+
"messages.request-send",
|
|
79
|
+
"outbox.read",
|
|
80
|
+
"outbox.request-retry",
|
|
81
|
+
"principal.search",
|
|
82
|
+
"relationship.end",
|
|
83
|
+
"relationships.read",
|
|
84
|
+
"block.remove",
|
|
85
|
+
"blocks.read",
|
|
86
|
+
"search.local",
|
|
87
|
+
"security.open-account",
|
|
88
|
+
"security.read",
|
|
89
|
+
"threads.read",
|
|
90
|
+
"threads.manage-local",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
export class HelloLynkNeutralHostWorkbenchReadModelError extends Error {
|
|
94
|
+
constructor(code, options = {}) {
|
|
95
|
+
super(code, options.cause ? { cause: options.cause } : undefined);
|
|
96
|
+
this.name = "HelloLynkNeutralHostWorkbenchReadModelError";
|
|
97
|
+
this.code = code;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the single Host-owned read model used by every client transport.
|
|
103
|
+
* Messages use the current indexed store's bounded projections; this
|
|
104
|
+
* module has no write path and never retains a mutable store object.
|
|
105
|
+
*/
|
|
106
|
+
export function createNeutralHostWorkbenchReadModel(options = {}) {
|
|
107
|
+
exactRecord(options, [
|
|
108
|
+
"subject",
|
|
109
|
+
"localDelegateLabels",
|
|
110
|
+
"hostGeneration",
|
|
111
|
+
"applicationChangeCursor",
|
|
112
|
+
"messageStore",
|
|
113
|
+
"contactStore",
|
|
114
|
+
"contactRequestStore",
|
|
115
|
+
"capabilities",
|
|
116
|
+
"hostStatus",
|
|
117
|
+
"relationshipMetadataState",
|
|
118
|
+
"connectedClients",
|
|
119
|
+
"changeEventTypes",
|
|
120
|
+
"snapshotRequired",
|
|
121
|
+
"now",
|
|
122
|
+
], "neutral_host_workbench_options_invalid");
|
|
123
|
+
|
|
124
|
+
const subject = normalizeSubject(options.subject);
|
|
125
|
+
const localDelegateLabels = new Map();
|
|
126
|
+
for (const item of array(options.localDelegateLabels ?? [], "neutral_host_workbench_local_delegate_labels_invalid")) {
|
|
127
|
+
exactRecord(item, ["principalId", "delegateId", "delegateLabel"], "neutral_host_workbench_local_delegate_label_invalid");
|
|
128
|
+
const principalId = safeId(item.principalId, "neutral_host_workbench_local_delegate_label_invalid");
|
|
129
|
+
const delegateId = safeId(item.delegateId, "neutral_host_workbench_local_delegate_label_invalid");
|
|
130
|
+
const delegateLabel = optionalText(item.delegateLabel, 120, "neutral_host_workbench_local_delegate_label_invalid");
|
|
131
|
+
if (principalId !== subject.principalId || delegateLabel == null) continue;
|
|
132
|
+
if (localDelegateLabels.has(delegateId) && localDelegateLabels.get(delegateId) !== delegateLabel) {
|
|
133
|
+
fail("neutral_host_workbench_local_delegate_label_conflict");
|
|
134
|
+
}
|
|
135
|
+
localDelegateLabels.set(delegateId, delegateLabel);
|
|
136
|
+
}
|
|
137
|
+
const hostGeneration = safeId(options.hostGeneration, "neutral_host_workbench_generation_invalid");
|
|
138
|
+
const applicationChangeCursor = normalizeChangeCursor(options.applicationChangeCursor);
|
|
139
|
+
const indexedMessageStore = options.messageStore;
|
|
140
|
+
if (["readWorkbenchSummary", "readWorkbenchRevision", "queryConversationPage", "readLocalHistoryStats"]
|
|
141
|
+
.some((method) => typeof indexedMessageStore?.[method] !== "function")) {
|
|
142
|
+
fail("neutral_host_workbench_indexed_reader_incomplete");
|
|
143
|
+
}
|
|
144
|
+
const readers = Object.freeze({
|
|
145
|
+
contacts: contactReader(options.contactStore, subject.delegateId),
|
|
146
|
+
contactRequests: contactRequestReader(options.contactRequestStore),
|
|
147
|
+
outbox: outboxReader(indexedMessageStore, subject),
|
|
148
|
+
});
|
|
149
|
+
const capabilities = normalizeCapabilities(options.capabilities);
|
|
150
|
+
const relationshipMetadataFields = Object.hasOwn(options, "relationshipMetadataState") ? {
|
|
151
|
+
relationshipMetadataState: oneOf(options.relationshipMetadataState, RELATIONSHIP_METADATA_STATES,
|
|
152
|
+
"neutral_host_workbench_relationship_metadata_state_invalid"),
|
|
153
|
+
} : {};
|
|
154
|
+
const hostStatus = oneOf(
|
|
155
|
+
options.hostStatus ?? "online",
|
|
156
|
+
HOST_STATES,
|
|
157
|
+
"neutral_host_workbench_host_status_invalid",
|
|
158
|
+
);
|
|
159
|
+
const connectedClients = normalizeConnectedClients(options.connectedClients ?? []);
|
|
160
|
+
const changeEventTypes = normalizeEventTypes(options.changeEventTypes ?? []);
|
|
161
|
+
const snapshotRequired = options.snapshotRequired === true;
|
|
162
|
+
const now = typeof options.now === "function" ? options.now : () => new Date();
|
|
163
|
+
const generationBaseline = isoTimestamp(now(), "neutral_host_workbench_time_invalid");
|
|
164
|
+
|
|
165
|
+
async function readSnapshot(request = {}, source) {
|
|
166
|
+
const acceptedRequest = normalizeReadRequest(request);
|
|
167
|
+
const acceptedSource = normalizeSource(source);
|
|
168
|
+
return readIndexedSnapshot(acceptedRequest, acceptedSource);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function readIndexedSnapshot(request, source) {
|
|
172
|
+
if (request.messageCursor != null && request.threadId == null) fail("neutral_host_workbench_message_cursor_thread_required");
|
|
173
|
+
if (request.historyBefore !== undefined && request.threadId == null) fail("neutral_host_workbench_thread_id_required");
|
|
174
|
+
const contactRequestRead = readers.contactRequests(Object.hasOwn(request, "contactRequestCursor")
|
|
175
|
+
? { cursor: request.contactRequestCursor } : {});
|
|
176
|
+
const metadata = await Promise.all([
|
|
177
|
+
contactRequestRead.then(() => readers.contacts()),
|
|
178
|
+
contactRequestRead,
|
|
179
|
+
]);
|
|
180
|
+
// Each indexed query holds a short store lock; a receiver may write between
|
|
181
|
+
// queries. Fence the complete local projection without holding that lock
|
|
182
|
+
// across a cloud lookup or a history scan. Retry reads only, reusing the
|
|
183
|
+
// relationship result rather than repeating its network/reconciliation work.
|
|
184
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
185
|
+
const revision = safeRevision(await indexedMessageStore.readWorkbenchRevision(),
|
|
186
|
+
"neutral_host_workbench_summary_invalid");
|
|
187
|
+
let snapshot, readError;
|
|
188
|
+
try { snapshot = await buildIndexedSnapshot(request, source, metadata); }
|
|
189
|
+
catch (error) { readError = error; }
|
|
190
|
+
const current = safeRevision(await indexedMessageStore.readWorkbenchRevision(),
|
|
191
|
+
"neutral_host_workbench_summary_invalid");
|
|
192
|
+
if (revision !== current) continue;
|
|
193
|
+
if (readError) throw readError;
|
|
194
|
+
return snapshot;
|
|
195
|
+
}
|
|
196
|
+
fail("neutral_host_workbench_snapshot_busy");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validateRelationshipMetadata(value) {
|
|
200
|
+
const state = normalizeContactRequestState(value);
|
|
201
|
+
const requests = normalizeContactRequests(state.requests, subject);
|
|
202
|
+
const projection = {
|
|
203
|
+
contactRequests: requests.filter((item) => item.direction === "incoming"),
|
|
204
|
+
outgoingContactRequests: requests.filter((item) => item.direction === "outgoing"),
|
|
205
|
+
contactRequestNextCursor: state.nextCursor,
|
|
206
|
+
relationships: normalizeRelationships(state.relationships),
|
|
207
|
+
blocks: normalizeBlocks(state.blocks),
|
|
208
|
+
};
|
|
209
|
+
assertNoSensitiveKeys(projection);
|
|
210
|
+
return deepFreeze(projection);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function buildIndexedSnapshot(request, source, metadata) {
|
|
214
|
+
const scope = { principalId: subject.principalId, delegateId: subject.delegateId, region: subject.region };
|
|
215
|
+
const [rawContacts, rawContactRequests] = metadata;
|
|
216
|
+
const [rawSummary, rawOutbox, searchThreadIds] = await Promise.all([
|
|
217
|
+
indexedMessageStore.readWorkbenchSummary(scope),
|
|
218
|
+
readers.outbox(),
|
|
219
|
+
request.query ? (typeof indexedMessageStore.searchWorkbenchThreads === "function"
|
|
220
|
+
// Filter and paginate the complete match set below. Truncating it here
|
|
221
|
+
// silently drops older-body matches in later conversations.
|
|
222
|
+
? indexedMessageStore.searchWorkbenchThreads({ scope, query: request.rawQuery, limit: Number.MAX_SAFE_INTEGER })
|
|
223
|
+
: Promise.reject(new HelloLynkNeutralHostWorkbenchReadModelError("neutral_host_workbench_search_reader_invalid")))
|
|
224
|
+
: Promise.resolve(null),
|
|
225
|
+
]);
|
|
226
|
+
exactRecord(rawSummary, ["threads", "revision", "latestTimestamp", "archivedThreadCount", "autoArchiveAfterHours", "hiddenThreadIds", "inboxReadBoundary"],
|
|
227
|
+
"neutral_host_workbench_summary_invalid");
|
|
228
|
+
if (Object.hasOwn(rawSummary, "inboxReadBoundary")
|
|
229
|
+
&& (!Number.isSafeInteger(rawSummary.inboxReadBoundary) || rawSummary.inboxReadBoundary < 0)) fail("neutral_host_workbench_summary_invalid");
|
|
230
|
+
const summaryRows = array(rawSummary.threads, "neutral_host_workbench_summary_invalid");
|
|
231
|
+
const contacts = normalizeContacts(rawContacts, subject);
|
|
232
|
+
if (contacts.length > 500) fail("neutral_host_workbench_contacts_invalid");
|
|
233
|
+
const aiReadAuthorization = { contactRequests: true };
|
|
234
|
+
const contactByHelloLynkId = new Map(contacts.map((item) => [item.hellolynkId, item]));
|
|
235
|
+
const lastMessages = summaryRows.flatMap((row) => row?.lastMessage == null ? [] : [row.lastMessage]);
|
|
236
|
+
const incoming = normalizeMessages(lastMessages, subject, "messages", localDelegateLabels);
|
|
237
|
+
const outgoing = normalizeMessages(lastMessages, subject, "outbox");
|
|
238
|
+
const projected = buildThreads(deduplicateMessages([...incoming, ...outgoing]), contactByHelloLynkId, subject);
|
|
239
|
+
const threadById = new Map(projected.threads.map((thread) => [thread.threadId, thread]));
|
|
240
|
+
const summaryIds = new Set();
|
|
241
|
+
for (const row of summaryRows) {
|
|
242
|
+
exactRecord(row, ["threadId", "binding", "legacy", "peerPrincipalId", "peerHelloLynkId",
|
|
243
|
+
"lastMessage", "unreadCount", "updatedAt", "archived", "autoReadAfterAi", "recentSendFailureCount"],
|
|
244
|
+
"neutral_host_workbench_summary_invalid");
|
|
245
|
+
const threadId = safeId(row.threadId, "neutral_host_workbench_thread_id_invalid");
|
|
246
|
+
if (summaryIds.has(threadId)) fail("neutral_host_workbench_summary_invalid");
|
|
247
|
+
summaryIds.add(threadId);
|
|
248
|
+
let thread = threadById.get(threadId);
|
|
249
|
+
if (!thread && row.lastMessage == null && row.binding != null) {
|
|
250
|
+
const binding = normalizeInstanceConversationBinding(row.binding,
|
|
251
|
+
{ region: subject.region, principalId: subject.principalId });
|
|
252
|
+
if (binding.localDelegateId !== subject.delegateId || instanceConversationKey(binding) !== threadId) {
|
|
253
|
+
fail("neutral_host_workbench_summary_scope_invalid");
|
|
254
|
+
}
|
|
255
|
+
const contact = contacts.find((item) => item.principalId === binding.peerPrincipalId) ?? null;
|
|
256
|
+
const instance = contact?.instances.find((item) => item.delegateId === binding.peerDelegateId) ?? null;
|
|
257
|
+
thread = {
|
|
258
|
+
threadId, conversationKind: "instance", localDelegateId: binding.localDelegateId,
|
|
259
|
+
peerDelegateId: binding.peerDelegateId, contactId: contact?.contactId ?? null,
|
|
260
|
+
peerPrincipalId: binding.peerPrincipalId, peerHelloLynkId: contact?.hellolynkId ?? null,
|
|
261
|
+
displayName: contact?.displayName ?? binding.peerPrincipalId,
|
|
262
|
+
delegateLabel: instance?.displayName ?? null, lastMessage: null,
|
|
263
|
+
unreadCount: 0, updatedAt: row.updatedAt,
|
|
264
|
+
};
|
|
265
|
+
threadById.set(threadId, thread);
|
|
266
|
+
}
|
|
267
|
+
if (!thread && row.lastMessage != null) fail("neutral_host_workbench_summary_scope_invalid");
|
|
268
|
+
if (!thread) continue;
|
|
269
|
+
if (!Number.isSafeInteger(row.unreadCount) || row.unreadCount < 0) fail("neutral_host_workbench_summary_invalid");
|
|
270
|
+
if (typeof row.archived !== "boolean") fail("neutral_host_workbench_summary_invalid");
|
|
271
|
+
thread.unreadCount = row.unreadCount;
|
|
272
|
+
thread.updatedAt = isoTimestamp(row.updatedAt, "neutral_host_workbench_summary_invalid");
|
|
273
|
+
thread.archived = row.archived === true;
|
|
274
|
+
if (Object.hasOwn(row, "recentSendFailureCount")) {
|
|
275
|
+
if (!Number.isSafeInteger(row.recentSendFailureCount) || row.recentSendFailureCount < 0
|
|
276
|
+
|| row.recentSendFailureCount > 5) fail("neutral_host_workbench_summary_invalid");
|
|
277
|
+
thread.recentSendFailureCount = row.recentSendFailureCount;
|
|
278
|
+
}
|
|
279
|
+
if (Object.hasOwn(row, "autoReadAfterAi")) {
|
|
280
|
+
if (typeof row.autoReadAfterAi !== "boolean" || row.legacy === true) fail("neutral_host_workbench_summary_invalid");
|
|
281
|
+
thread.autoReadAfterAi = row.autoReadAfterAi;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const hiddenThreadIds = new Set(array(rawSummary.hiddenThreadIds ?? [], "neutral_host_workbench_summary_invalid")
|
|
285
|
+
.map((id) => safeId(id, "neutral_host_workbench_thread_id_invalid")));
|
|
286
|
+
const canonicalThreads = [...threadById.values()].filter((thread) => thread.conversationKind === "instance"
|
|
287
|
+
&& thread.localDelegateId === subject.delegateId && !hiddenThreadIds.has(thread.threadId));
|
|
288
|
+
canonicalThreads.sort((left, right) => compareText(right.updatedAt, left.updatedAt)
|
|
289
|
+
|| compareText(left.threadId, right.threadId));
|
|
290
|
+
const { contactRequests, outgoingContactRequests, contactRequestNextCursor, relationships, blocks }
|
|
291
|
+
= validateRelationshipMetadata(rawContactRequests);
|
|
292
|
+
const outbox = normalizeOutbox(rawOutbox, subject);
|
|
293
|
+
const security = { hostStatus, connectedClients };
|
|
294
|
+
// Keep the Host stream cursor captured when this read model was created.
|
|
295
|
+
// Together with hostGeneration it lets the UI recognize notifications
|
|
296
|
+
// already included in a snapshot; it never substitutes for revision checks.
|
|
297
|
+
const changeCursor = applicationChangeCursor;
|
|
298
|
+
const revision = `wbr_${digest(stableJson({
|
|
299
|
+
contractId: HELLOLYNK_NEUTRAL_HOST_WORKBENCH_READ_MODEL_CONTRACT,
|
|
300
|
+
hostGeneration, applicationChangeCursor, subject, capabilities,
|
|
301
|
+
storeRevision: safeRevision(String(rawSummary.revision), "neutral_host_workbench_summary_invalid"),
|
|
302
|
+
contacts, aiReadAuthorization, relationships, ...relationshipMetadataFields,
|
|
303
|
+
blocks, security, changeEventTypes, snapshotRequired,
|
|
304
|
+
})).slice(0, 48)}`;
|
|
305
|
+
const searchMatches = searchThreadIds == null ? null : new Set(array(searchThreadIds,
|
|
306
|
+
"neutral_host_workbench_search_invalid").map((id) => safeId(id, "neutral_host_workbench_thread_id_invalid")));
|
|
307
|
+
const matchingThreads = filterThreads(canonicalThreads, [], request.query, request.filter, searchMatches);
|
|
308
|
+
const selectedThread = request.threadId == null ? null
|
|
309
|
+
: canonicalThreads.find((item) => item.threadId === request.threadId);
|
|
310
|
+
if (request.threadId != null && !selectedThread) fail("neutral_host_workbench_thread_not_found");
|
|
311
|
+
const threadFingerprint = cursorFingerprint({ kind: "threads", hostGeneration, revision,
|
|
312
|
+
query: request.query, filter: request.filter });
|
|
313
|
+
const messageFingerprint = selectedThread ? cursorFingerprint({ kind: "indexed-messages-v1", hostGeneration, subject,
|
|
314
|
+
query: request.query, filter: request.filter, threadId: selectedThread.threadId }) : null;
|
|
315
|
+
const decodedMessageCursor = selectedThread ? decodeIndexedMessageCursor(request.messageCursor, messageFingerprint) : null;
|
|
316
|
+
const windowRead = ["from", "older-window", "newer-window"].includes(decodedMessageCursor?.direction);
|
|
317
|
+
let threadItems;
|
|
318
|
+
let threadNextCursor;
|
|
319
|
+
if (request.messageCursor != null && !windowRead) {
|
|
320
|
+
threadItems = [selectedThread];
|
|
321
|
+
threadNextCursor = null;
|
|
322
|
+
} else {
|
|
323
|
+
const offset = decodeCursor(request.threadCursor, "t", threadFingerprint, matchingThreads.length);
|
|
324
|
+
threadItems = matchingThreads.slice(offset, offset + THREAD_PAGE_SIZE);
|
|
325
|
+
const next = offset + threadItems.length;
|
|
326
|
+
threadNextCursor = next < matchingThreads.length ? encodeCursor("t", next, threadFingerprint) : null;
|
|
327
|
+
if (selectedThread && request.threadCursor == null
|
|
328
|
+
&& !threadItems.some((item) => item.threadId === selectedThread.threadId)) threadItems = [...threadItems, selectedThread];
|
|
329
|
+
}
|
|
330
|
+
let messageItems = [];
|
|
331
|
+
let messageNextCursor = null;
|
|
332
|
+
let messagePreviousCursor = null;
|
|
333
|
+
let messageRefreshCursor = null;
|
|
334
|
+
let historySelection = null;
|
|
335
|
+
if (selectedThread) {
|
|
336
|
+
const fingerprint = messageFingerprint;
|
|
337
|
+
const decoded = decodedMessageCursor;
|
|
338
|
+
const messageLimit = windowRead ? 200 : MESSAGE_PAGE_SIZE;
|
|
339
|
+
const page = await indexedMessageStore.queryConversationPage({ scope, threadId: selectedThread.threadId,
|
|
340
|
+
anchor: decoded?.anchor ?? null, direction: decoded?.direction ?? "before", limit: messageLimit });
|
|
341
|
+
exactRecord(page, ["messages", "hasEarlier", "hasLater"], "neutral_host_workbench_message_page_invalid");
|
|
342
|
+
const rawPage = array(page.messages, "neutral_host_workbench_message_page_invalid");
|
|
343
|
+
if (rawPage.length > messageLimit || typeof page.hasEarlier !== "boolean" || typeof page.hasLater !== "boolean") {
|
|
344
|
+
fail("neutral_host_workbench_message_page_invalid");
|
|
345
|
+
}
|
|
346
|
+
const pageMessages = deduplicateMessages([
|
|
347
|
+
...normalizeMessages(rawPage, subject, "messages", localDelegateLabels),
|
|
348
|
+
...normalizeMessages(rawPage, subject, "outbox"),
|
|
349
|
+
]);
|
|
350
|
+
const pageProjected = buildThreads(pageMessages, contactByHelloLynkId, subject).messages;
|
|
351
|
+
const pageKey = (item) => `${item.direction ?? "incoming"}\0${item.localMessageId ?? item.id}\0${item.clientMessageId ?? item.id}`;
|
|
352
|
+
const projectedById = new Map(pageProjected.map((item) => [pageKey(item), item]));
|
|
353
|
+
messageItems = rawPage.map((item) => projectedById.get(pageKey(item)));
|
|
354
|
+
if (messageItems.some((item) => !item || item.threadId !== selectedThread.threadId)) {
|
|
355
|
+
fail("neutral_host_workbench_message_page_scope_invalid");
|
|
356
|
+
}
|
|
357
|
+
if (messageItems.length) {
|
|
358
|
+
messageRefreshCursor = encodeIndexedMessageCursor("from", messageItems[0], fingerprint);
|
|
359
|
+
messageNextCursor = page.hasEarlier ? encodeIndexedMessageCursor("older-window", messageItems[0], fingerprint) : null;
|
|
360
|
+
messagePreviousCursor = page.hasLater
|
|
361
|
+
? encodeIndexedMessageCursor("newer-window", messageItems.at(-1), fingerprint) : null;
|
|
362
|
+
}
|
|
363
|
+
if (Object.hasOwn(request, "historyBefore")) {
|
|
364
|
+
historySelection = await indexedMessageStore.readLocalHistoryStats({ scope,
|
|
365
|
+
threadId: selectedThread.threadId, before: request.historyBefore });
|
|
366
|
+
if (historySelection?.threadId !== selectedThread.threadId) fail("neutral_host_workbench_history_scope_invalid");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const latestTimestamp = rawSummary.latestTimestamp == null ? generationBaseline
|
|
370
|
+
: isoTimestamp(rawSummary.latestTimestamp, "neutral_host_workbench_summary_invalid");
|
|
371
|
+
if (!Number.isSafeInteger(rawSummary.archivedThreadCount) || rawSummary.archivedThreadCount < 0
|
|
372
|
+
|| (rawSummary.autoArchiveAfterHours !== null && rawSummary.autoArchiveAfterHours !== 24)) {
|
|
373
|
+
fail("neutral_host_workbench_summary_invalid");
|
|
374
|
+
}
|
|
375
|
+
const generatedAt = deterministicGeneratedAt(generationBaseline,
|
|
376
|
+
[{ updatedAt: latestTimestamp }], contacts, relationships, blocks, outbox);
|
|
377
|
+
const snapshot = {
|
|
378
|
+
contractId: HELLOLYNK_WORKBENCH_SNAPSHOT_CONTRACT,
|
|
379
|
+
schemaVersion: HELLOLYNK_WORKBENCH_SNAPSHOT_SCHEMA_VERSION,
|
|
380
|
+
compatibility: { minReaderVersion: HELLOLYNK_WORKBENCH_SNAPSHOT_SCHEMA_VERSION,
|
|
381
|
+
commandVersion: HELLOLYNK_WORKBENCH_SCHEMA_VERSION },
|
|
382
|
+
hostGeneration, commandWindow: null, revision, changeCursor, generatedAt, source,
|
|
383
|
+
...(Object.hasOwn(rawSummary, "inboxReadBoundary") ? { inboxReadBoundary: rawSummary.inboxReadBoundary } : {}),
|
|
384
|
+
subject, capabilities,
|
|
385
|
+
threads: { items: threadItems, nextCursor: threadNextCursor },
|
|
386
|
+
messages: { threadId: selectedThread?.threadId ?? null, items: messageItems,
|
|
387
|
+
nextCursor: messageNextCursor, previousCursor: messagePreviousCursor, refreshCursor: messageRefreshCursor },
|
|
388
|
+
outbox, contacts, aiReadAuthorization, contactRequests, outgoingContactRequests,
|
|
389
|
+
contactRequestNextCursor, relationships,
|
|
390
|
+
...relationshipMetadataFields, blocks, security,
|
|
391
|
+
localHistory: { autoArchiveAfterHours: rawSummary.autoArchiveAfterHours,
|
|
392
|
+
archivedThreadCount: rawSummary.archivedThreadCount, selection: historySelection },
|
|
393
|
+
changes: { cursor: changeCursor, snapshotRequired, eventTypes: changeEventTypes },
|
|
394
|
+
};
|
|
395
|
+
assertNoSensitiveKeys(snapshot);
|
|
396
|
+
return deepFreeze(snapshot);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return Object.freeze({
|
|
400
|
+
contractId: HELLOLYNK_NEUTRAL_HOST_WORKBENCH_READ_MODEL_CONTRACT,
|
|
401
|
+
readSnapshot,
|
|
402
|
+
// Background display refreshes reuse the same projection validation without
|
|
403
|
+
// reading unrelated message, outbox or local-contact stores.
|
|
404
|
+
validateRelationshipMetadata,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function normalizeReadRequest(value) {
|
|
409
|
+
exactRecord(
|
|
410
|
+
value,
|
|
411
|
+
["threadId", "threadCursor", "messageCursor", "contactRequestCursor", "query", "filter", "historyBefore"],
|
|
412
|
+
"neutral_host_workbench_read_request_invalid",
|
|
413
|
+
);
|
|
414
|
+
const rawQuery = value.query ?? "";
|
|
415
|
+
return Object.freeze({
|
|
416
|
+
threadId: value.threadId == null
|
|
417
|
+
? null
|
|
418
|
+
: safeId(value.threadId, "neutral_host_workbench_thread_id_invalid"),
|
|
419
|
+
threadCursor: value.threadCursor == null
|
|
420
|
+
? null
|
|
421
|
+
: safeRevision(value.threadCursor, "neutral_host_workbench_thread_cursor_invalid"),
|
|
422
|
+
messageCursor: value.messageCursor == null
|
|
423
|
+
? null
|
|
424
|
+
: safeMessageCursor(value.messageCursor),
|
|
425
|
+
...(Object.hasOwn(value, "historyBefore") ? { historyBefore: value.historyBefore == null ? null
|
|
426
|
+
: isoTimestamp(value.historyBefore, "neutral_host_workbench_history_before_invalid") } : {}),
|
|
427
|
+
...(Object.hasOwn(value, "contactRequestCursor") ? {
|
|
428
|
+
contactRequestCursor: value.contactRequestCursor == null
|
|
429
|
+
? null
|
|
430
|
+
: contactRequestCursor(
|
|
431
|
+
value.contactRequestCursor,
|
|
432
|
+
"neutral_host_workbench_contact_request_cursor_invalid",
|
|
433
|
+
),
|
|
434
|
+
} : {}),
|
|
435
|
+
query: normalizeQuery(rawQuery),
|
|
436
|
+
rawQuery,
|
|
437
|
+
filter: oneOf(value.filter ?? "all", FILTERS, "neutral_host_workbench_filter_invalid"),
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function normalizeSubject(value) {
|
|
442
|
+
exactRecord(value, [
|
|
443
|
+
"region",
|
|
444
|
+
"principalId",
|
|
445
|
+
"principalLabel",
|
|
446
|
+
"hellolynkId",
|
|
447
|
+
"delegateId",
|
|
448
|
+
"delegateLabel",
|
|
449
|
+
"hostInstallationId",
|
|
450
|
+
"authorizationVersion",
|
|
451
|
+
], "neutral_host_workbench_subject_invalid");
|
|
452
|
+
const authorizationVersion = Number(value.authorizationVersion);
|
|
453
|
+
if (!Number.isSafeInteger(authorizationVersion) || authorizationVersion < 1) {
|
|
454
|
+
fail("neutral_host_workbench_authorization_version_invalid");
|
|
455
|
+
}
|
|
456
|
+
return deepFreeze({
|
|
457
|
+
region: safeId(value.region, "neutral_host_workbench_subject_invalid"),
|
|
458
|
+
principalId: safeId(value.principalId, "neutral_host_workbench_subject_invalid"),
|
|
459
|
+
principalLabel: optionalText(value.principalLabel, 120, "neutral_host_workbench_subject_invalid"),
|
|
460
|
+
hellolynkId: safeHelloLynkId(value.hellolynkId, "neutral_host_workbench_subject_invalid"),
|
|
461
|
+
delegateId: safeId(value.delegateId, "neutral_host_workbench_subject_invalid"),
|
|
462
|
+
delegateLabel: optionalText(value.delegateLabel, 120, "neutral_host_workbench_subject_invalid"),
|
|
463
|
+
hostInstallationId: safeId(value.hostInstallationId, "neutral_host_workbench_subject_invalid"),
|
|
464
|
+
authorizationVersion,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function normalizeSource(value) {
|
|
469
|
+
exactRecord(value, ["mode", "transport", "clientId", "clientLabel"], "neutral_host_workbench_source_invalid");
|
|
470
|
+
if (value.mode !== "neutral-host" || !TRANSPORTS.has(value.transport)) {
|
|
471
|
+
fail("neutral_host_workbench_source_invalid");
|
|
472
|
+
}
|
|
473
|
+
return deepFreeze({
|
|
474
|
+
mode: "neutral-host",
|
|
475
|
+
transport: value.transport,
|
|
476
|
+
clientId: safeId(value.clientId, "neutral_host_workbench_source_invalid"),
|
|
477
|
+
clientLabel: text(value.clientLabel, 80, "neutral_host_workbench_source_invalid"),
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function outboxReader(store, subject) {
|
|
482
|
+
if (typeof store.queryOutbox !== "function") fail("neutral_host_workbench_outbox_reader_invalid");
|
|
483
|
+
return async () => array(await store.queryOutbox({
|
|
484
|
+
scope: { principalId: subject.principalId, delegateId: subject.delegateId, region: subject.region },
|
|
485
|
+
limit: 100, cursor: null,
|
|
486
|
+
}), "neutral_host_workbench_outbox_invalid");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function contactReader(store, delegateId) {
|
|
490
|
+
if (!store) fail("neutral_host_workbench_contact_reader_invalid");
|
|
491
|
+
if (typeof store.listForDelegate === "function") {
|
|
492
|
+
return async () => array(
|
|
493
|
+
await store.listForDelegate(delegateId),
|
|
494
|
+
"neutral_host_workbench_contacts_invalid",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
if (typeof store.list === "function") {
|
|
498
|
+
return async () => array(await store.list(), "neutral_host_workbench_contacts_invalid");
|
|
499
|
+
}
|
|
500
|
+
fail("neutral_host_workbench_contact_reader_invalid");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function contactRequestReader(store) {
|
|
504
|
+
if (!store || typeof store.listForHuman !== "function") {
|
|
505
|
+
fail("neutral_host_workbench_contact_request_reader_invalid");
|
|
506
|
+
}
|
|
507
|
+
return async (input) => store.listForHuman(input);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function normalizeContactRequestState(value) {
|
|
511
|
+
if (Array.isArray(value)) {
|
|
512
|
+
return { requests: value, nextCursor: null, relationships: [], blocks: [] };
|
|
513
|
+
}
|
|
514
|
+
exactRecord(
|
|
515
|
+
value,
|
|
516
|
+
["requests", "nextCursor", "relationships", "blocks"],
|
|
517
|
+
"neutral_host_workbench_contact_request_state_invalid",
|
|
518
|
+
);
|
|
519
|
+
return {
|
|
520
|
+
requests: array(value.requests, "neutral_host_workbench_contact_requests_invalid"),
|
|
521
|
+
nextCursor: value.nextCursor == null
|
|
522
|
+
? null
|
|
523
|
+
: contactRequestCursor(
|
|
524
|
+
value.nextCursor,
|
|
525
|
+
"neutral_host_workbench_contact_request_cursor_invalid",
|
|
526
|
+
),
|
|
527
|
+
relationships: array(
|
|
528
|
+
value.relationships,
|
|
529
|
+
"neutral_host_workbench_relationships_invalid",
|
|
530
|
+
),
|
|
531
|
+
blocks: array(value.blocks, "neutral_host_workbench_blocks_invalid"),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function normalizeMessages(value, subject, origin, localDelegateLabels = null) {
|
|
536
|
+
return array(value, "neutral_host_workbench_messages_invalid")
|
|
537
|
+
.filter((item) => {
|
|
538
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
539
|
+
fail("neutral_host_workbench_message_invalid");
|
|
540
|
+
}
|
|
541
|
+
if (item.messageType === "local-deleted") return false;
|
|
542
|
+
if (origin === "messages") return (item.direction ?? "incoming") === "incoming";
|
|
543
|
+
return (item.direction ?? "outgoing") === "outgoing";
|
|
544
|
+
})
|
|
545
|
+
.filter((item) => origin !== "outbox"
|
|
546
|
+
|| item.senderPrincipalId === subject.principalId)
|
|
547
|
+
.map((item) => {
|
|
548
|
+
const direction = origin === "messages" ? "incoming" : "outgoing";
|
|
549
|
+
let conversationBinding = null;
|
|
550
|
+
try {
|
|
551
|
+
const message = { ...item, direction };
|
|
552
|
+
const identityContext = { region: subject.region, principalId: subject.principalId };
|
|
553
|
+
conversationBinding = getMessageConversationBinding(message, identityContext)
|
|
554
|
+
|| getOutgoingConversationIntent(message, identityContext);
|
|
555
|
+
} catch {
|
|
556
|
+
// Invalid or contradictory old evidence remains readable only in
|
|
557
|
+
// legacy history. The current Host instance cannot repair its identity.
|
|
558
|
+
}
|
|
559
|
+
const peerPrincipalId = direction === "incoming"
|
|
560
|
+
? optionalSafeId(item.peerPrincipalId ?? item.senderPrincipalId ?? conversationBinding?.peerPrincipalId)
|
|
561
|
+
: optionalSafeId(item.peerPrincipalId ?? item.recipientPrincipalId ?? conversationBinding?.peerPrincipalId);
|
|
562
|
+
const peerHelloLynkId = direction === "incoming"
|
|
563
|
+
? optionalHelloLynkId(item.peerHelloLynkId ?? item.senderHelloLynkId)
|
|
564
|
+
: optionalHelloLynkId(item.recipientHelloLynkId ?? item.peerHelloLynkId);
|
|
565
|
+
requirePeer(peerPrincipalId, peerHelloLynkId);
|
|
566
|
+
const senderPrincipalId = optionalSafeId(item.senderPrincipalId);
|
|
567
|
+
const senderDelegateId = optionalSafeId(item.senderDelegateId);
|
|
568
|
+
if (direction === "incoming" && senderPrincipalId != null && peerPrincipalId != null
|
|
569
|
+
&& senderPrincipalId !== peerPrincipalId) {
|
|
570
|
+
fail("neutral_host_workbench_message_sender_invalid");
|
|
571
|
+
}
|
|
572
|
+
const provenance = normalizeMessageProvenance(item.authorizationScope, item.intentSource);
|
|
573
|
+
if (provenance.authorizationScope != null
|
|
574
|
+
&& (senderPrincipalId == null || senderDelegateId == null)) {
|
|
575
|
+
fail("neutral_host_workbench_message_provenance_invalid");
|
|
576
|
+
}
|
|
577
|
+
const createdAt = isoTimestamp(
|
|
578
|
+
item.createdAt ?? item.receivedAt,
|
|
579
|
+
"neutral_host_workbench_message_time_invalid",
|
|
580
|
+
);
|
|
581
|
+
const updatedAt = isoTimestamp(
|
|
582
|
+
item.updatedAt ?? item.readAt ?? item.receivedAt ?? item.createdAt,
|
|
583
|
+
"neutral_host_workbench_message_time_invalid",
|
|
584
|
+
);
|
|
585
|
+
const unread = direction === "incoming"
|
|
586
|
+
? (typeof item.unread === "boolean" ? item.unread : item.status === "received")
|
|
587
|
+
: false;
|
|
588
|
+
return {
|
|
589
|
+
localMessageId: safeId(
|
|
590
|
+
item.localMessageId ?? item.id,
|
|
591
|
+
"neutral_host_workbench_message_id_invalid",
|
|
592
|
+
),
|
|
593
|
+
clientMessageId: safeId(
|
|
594
|
+
item.clientMessageId ?? item.id,
|
|
595
|
+
"neutral_host_workbench_message_id_invalid",
|
|
596
|
+
),
|
|
597
|
+
direction,
|
|
598
|
+
conversationBinding,
|
|
599
|
+
peerPrincipalId,
|
|
600
|
+
peerHelloLynkId,
|
|
601
|
+
senderPrincipalId,
|
|
602
|
+
senderDelegateId,
|
|
603
|
+
authorizationScope: provenance.authorizationScope,
|
|
604
|
+
intentSource: provenance.intentSource,
|
|
605
|
+
senderPrincipalDisplayName: optionalText(
|
|
606
|
+
item.senderPrincipalDisplayName,
|
|
607
|
+
160,
|
|
608
|
+
"neutral_host_workbench_message_label_invalid",
|
|
609
|
+
),
|
|
610
|
+
senderDelegateDisplayName: optionalText(
|
|
611
|
+
item.senderDelegateDisplayName,
|
|
612
|
+
120,
|
|
613
|
+
"neutral_host_workbench_message_label_invalid",
|
|
614
|
+
),
|
|
615
|
+
senderDelegateFallbackLabel: direction === "incoming" && conversationBinding != null
|
|
616
|
+
&& senderPrincipalId === subject.principalId
|
|
617
|
+
&& conversationBinding.peerPrincipalId === subject.principalId
|
|
618
|
+
&& senderDelegateId === conversationBinding.peerDelegateId
|
|
619
|
+
? localDelegateLabels?.get(senderDelegateId) ?? null : null,
|
|
620
|
+
text: text(item.text ?? "", 64 * 1024, "neutral_host_workbench_message_text_invalid", true),
|
|
621
|
+
...(direction === "incoming" && item.automaticDeliveryState === "expired" ? { automaticDeliveryState: "expired" } : {}),
|
|
622
|
+
status: errorCode(
|
|
623
|
+
item.status ?? (direction === "incoming" ? (unread ? "received" : "fetched") : "pending"),
|
|
624
|
+
"neutral_host_workbench_message_status_invalid",
|
|
625
|
+
),
|
|
626
|
+
unread,
|
|
627
|
+
createdAt,
|
|
628
|
+
updatedAt,
|
|
629
|
+
};
|
|
630
|
+
})
|
|
631
|
+
// A verified binding belongs to one local AI. Sharing the principal's
|
|
632
|
+
// physical store must not project that AI's messages into another AI view.
|
|
633
|
+
// Unbound legacy rows stay read-only; current selection is not evidence
|
|
634
|
+
// from which to invent their original recipient.
|
|
635
|
+
.filter((message) => message.conversationBinding == null
|
|
636
|
+
|| message.conversationBinding.localDelegateId === subject.delegateId);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function deduplicateMessages(messages) {
|
|
640
|
+
const byKey = new Map();
|
|
641
|
+
for (const message of messages) {
|
|
642
|
+
const key = `${message.direction}\0${message.localMessageId}\0${message.clientMessageId}`;
|
|
643
|
+
const previous = byKey.get(key);
|
|
644
|
+
if (previous && stableJson(previous) !== stableJson(message)) {
|
|
645
|
+
fail("neutral_host_workbench_message_conflict");
|
|
646
|
+
}
|
|
647
|
+
byKey.set(key, message);
|
|
648
|
+
}
|
|
649
|
+
return [...byKey.values()];
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function normalizeContacts(value, subject) {
|
|
653
|
+
const contacts = array(value, "neutral_host_workbench_contacts_invalid").map((item) => {
|
|
654
|
+
record(item, "neutral_host_workbench_contact_invalid");
|
|
655
|
+
const principalId = optionalSafeId(item.principalId);
|
|
656
|
+
const hellolynkId = safeHelloLynkId(item.hellolynkId, "neutral_host_workbench_contact_hellolynk_id_invalid");
|
|
657
|
+
const instances = array(item.instances ?? [], "neutral_host_workbench_contact_instances_invalid").map((instance) => {
|
|
658
|
+
record(instance, "neutral_host_workbench_contact_instance_invalid");
|
|
659
|
+
if (principalId == null) fail("neutral_host_workbench_contact_instance_principal_required");
|
|
660
|
+
const delegateId = safeId(instance.delegateId, "neutral_host_workbench_contact_instance_invalid");
|
|
661
|
+
const region = subject.region === "international" ? "intl" : subject.region;
|
|
662
|
+
const conversationKey = instanceConversationKey(normalizeInstanceConversationBinding({
|
|
663
|
+
schemaVersion: 1, region, localPrincipalId: subject.principalId,
|
|
664
|
+
localDelegateId: subject.delegateId, peerPrincipalId: principalId, peerDelegateId: delegateId,
|
|
665
|
+
}, { region: subject.region, principalId: subject.principalId }));
|
|
666
|
+
return {
|
|
667
|
+
delegateId,
|
|
668
|
+
clientType: oneOf(instance.clientType, CLIENT_TYPES, "neutral_host_workbench_contact_instance_invalid"),
|
|
669
|
+
displayName: text(instance.displayName, 120, "neutral_host_workbench_contact_instance_invalid"),
|
|
670
|
+
createdAt: isoTimestamp(instance.createdAt, "neutral_host_workbench_contact_instance_invalid"),
|
|
671
|
+
updatedAt: isoTimestamp(instance.updatedAt, "neutral_host_workbench_contact_instance_invalid"),
|
|
672
|
+
autonomousSendAuthorized: item.autonomousSendAuthorized === true && instance.autonomousSendAuthorized === true,
|
|
673
|
+
continuousReadAuthorized: true,
|
|
674
|
+
conversationKey,
|
|
675
|
+
};
|
|
676
|
+
}).filter((instance) => principalId !== subject.principalId
|
|
677
|
+
|| instance.delegateId !== subject.delegateId);
|
|
678
|
+
return {
|
|
679
|
+
contactId: safeId(item.contactId, "neutral_host_workbench_contact_id_invalid"),
|
|
680
|
+
principalId,
|
|
681
|
+
hellolynkId,
|
|
682
|
+
displayName: text(item.displayName, 160, "neutral_host_workbench_contact_label_invalid"),
|
|
683
|
+
autonomousSendAuthorized: item.autonomousSendAuthorized === true,
|
|
684
|
+
continuousReadAuthorized: true,
|
|
685
|
+
instances,
|
|
686
|
+
updatedAt: isoTimestamp(item.updatedAt, "neutral_host_workbench_contact_time_invalid"),
|
|
687
|
+
};
|
|
688
|
+
});
|
|
689
|
+
contacts.sort((left, right) => compareText(left.displayName, right.displayName)
|
|
690
|
+
|| compareText(left.contactId, right.contactId));
|
|
691
|
+
assertUnique(contacts, (item) => item.contactId, "neutral_host_workbench_contact_conflict");
|
|
692
|
+
for (const contact of contacts) assertUnique(contact.instances, (item) => item.delegateId,
|
|
693
|
+
"neutral_host_workbench_contact_instance_conflict");
|
|
694
|
+
return contacts;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function normalizeContactRequests(value, subject) {
|
|
698
|
+
const requests = array(value, "neutral_host_workbench_contact_requests_invalid").map((item) => {
|
|
699
|
+
record(item, "neutral_host_workbench_contact_request_invalid");
|
|
700
|
+
if (item.humanOnly !== true || (item.modelVisibility != null && item.modelVisibility !== "none")) {
|
|
701
|
+
fail("neutral_host_workbench_contact_request_visibility_invalid");
|
|
702
|
+
}
|
|
703
|
+
const sender = item.sender && typeof item.sender === "object" ? item.sender : item;
|
|
704
|
+
const direction = oneOf(
|
|
705
|
+
item.direction ?? "incoming",
|
|
706
|
+
new Set(["incoming", "outgoing"]),
|
|
707
|
+
"neutral_host_workbench_contact_request_direction_invalid",
|
|
708
|
+
);
|
|
709
|
+
const other = item.otherPrincipal && typeof item.otherPrincipal === "object"
|
|
710
|
+
? item.otherPrincipal
|
|
711
|
+
: sender;
|
|
712
|
+
const state = oneOf(
|
|
713
|
+
item.state ?? item.status,
|
|
714
|
+
CONTACT_REQUEST_STATES,
|
|
715
|
+
"neutral_host_workbench_contact_request_state_invalid",
|
|
716
|
+
);
|
|
717
|
+
const hasDecisionOutcome = Object.hasOwn(item, "decisionOutcome");
|
|
718
|
+
const hasDecisionAction = Object.hasOwn(item, "decisionAction");
|
|
719
|
+
if (hasDecisionOutcome !== hasDecisionAction) {
|
|
720
|
+
fail("neutral_host_workbench_contact_request_decision_invalid");
|
|
721
|
+
}
|
|
722
|
+
const decisionOutcome = hasDecisionOutcome
|
|
723
|
+
? item.decisionOutcome
|
|
724
|
+
: null;
|
|
725
|
+
const decisionAction = hasDecisionAction ? item.decisionAction : null;
|
|
726
|
+
const actionReady = hasDecisionOutcome ? item.actionReady === true : false;
|
|
727
|
+
if ((hasDecisionOutcome && typeof item.actionReady !== "boolean")
|
|
728
|
+
|| !new Set([null, "unknown", "recovery-required"]).has(decisionOutcome)
|
|
729
|
+
|| (decisionAction != null && !new Set(["accept", "reject", "block"]).has(decisionAction))
|
|
730
|
+
|| (actionReady && (state !== "pending"
|
|
731
|
+
|| decisionOutcome != null
|
|
732
|
+
|| decisionAction != null))
|
|
733
|
+
|| (state !== "pending" && actionReady)
|
|
734
|
+
|| ((decisionOutcome === "unknown") !== (decisionAction != null))
|
|
735
|
+
|| (decisionOutcome != null && (state !== "pending" || actionReady))
|
|
736
|
+
) {
|
|
737
|
+
fail("neutral_host_workbench_contact_request_decision_invalid");
|
|
738
|
+
}
|
|
739
|
+
return {
|
|
740
|
+
requestId: safeId(
|
|
741
|
+
item.serverContactRequestId ?? item.requestId ?? item.localRecordId,
|
|
742
|
+
"neutral_host_workbench_contact_request_id_invalid",
|
|
743
|
+
),
|
|
744
|
+
senderHelloLynkId: safeHelloLynkId(
|
|
745
|
+
direction === "incoming"
|
|
746
|
+
? (sender.hellolynkId ?? item.senderHelloLynkId ?? other.hellolynkId)
|
|
747
|
+
: subject.hellolynkId,
|
|
748
|
+
"neutral_host_workbench_contact_request_sender_invalid",
|
|
749
|
+
),
|
|
750
|
+
senderLabel: optionalText(
|
|
751
|
+
direction === "incoming"
|
|
752
|
+
? (sender.verifiedDisplayName ?? item.senderLabel
|
|
753
|
+
?? item.senderPrincipalDisplayName ?? other.displayName)
|
|
754
|
+
: subject.principalLabel,
|
|
755
|
+
160,
|
|
756
|
+
"neutral_host_workbench_contact_request_label_invalid",
|
|
757
|
+
),
|
|
758
|
+
direction,
|
|
759
|
+
otherHelloLynkId: safeHelloLynkId(
|
|
760
|
+
other.hellolynkId ?? item.otherHelloLynkId ?? item.senderHelloLynkId,
|
|
761
|
+
"neutral_host_workbench_contact_request_sender_invalid",
|
|
762
|
+
),
|
|
763
|
+
otherLabel: optionalText(
|
|
764
|
+
other.displayName ?? other.verifiedDisplayName ?? item.otherLabel
|
|
765
|
+
?? item.senderLabel ?? item.senderPrincipalDisplayName,
|
|
766
|
+
160,
|
|
767
|
+
"neutral_host_workbench_contact_request_label_invalid",
|
|
768
|
+
),
|
|
769
|
+
state,
|
|
770
|
+
actionReady,
|
|
771
|
+
decisionOutcome,
|
|
772
|
+
decisionAction,
|
|
773
|
+
humanOnly: true,
|
|
774
|
+
...(item.introText == null ? {} : {
|
|
775
|
+
introText: text(item.introText, 80, "neutral_host_workbench_contact_request_intro_invalid", true),
|
|
776
|
+
}),
|
|
777
|
+
receivedAt: isoTimestamp(
|
|
778
|
+
item.receivedAt ?? item.createdAt,
|
|
779
|
+
"neutral_host_workbench_contact_request_time_invalid",
|
|
780
|
+
),
|
|
781
|
+
};
|
|
782
|
+
});
|
|
783
|
+
requests.sort((left, right) => compareText(right.receivedAt, left.receivedAt)
|
|
784
|
+
|| compareText(left.requestId, right.requestId));
|
|
785
|
+
assertUnique(requests, (item) => item.requestId, "neutral_host_workbench_contact_request_conflict");
|
|
786
|
+
return requests;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function normalizeRelationships(value) {
|
|
790
|
+
const relationships = array(value, "neutral_host_workbench_relationships_invalid")
|
|
791
|
+
.map((item) => {
|
|
792
|
+
record(item, "neutral_host_workbench_relationship_invalid");
|
|
793
|
+
const other = record(
|
|
794
|
+
item.otherPrincipal,
|
|
795
|
+
"neutral_host_workbench_relationship_invalid",
|
|
796
|
+
);
|
|
797
|
+
const status = oneOf(
|
|
798
|
+
item.status,
|
|
799
|
+
new Set(["active", "ended"]),
|
|
800
|
+
"neutral_host_workbench_relationship_status_invalid",
|
|
801
|
+
);
|
|
802
|
+
const endedAt = item.endedAt == null
|
|
803
|
+
? null
|
|
804
|
+
: isoTimestamp(item.endedAt, "neutral_host_workbench_relationship_time_invalid");
|
|
805
|
+
if ((status === "active") !== (endedAt == null)) {
|
|
806
|
+
fail("neutral_host_workbench_relationship_status_invalid");
|
|
807
|
+
}
|
|
808
|
+
return {
|
|
809
|
+
relationshipId: safeId(
|
|
810
|
+
item.relationshipId,
|
|
811
|
+
"neutral_host_workbench_relationship_id_invalid",
|
|
812
|
+
),
|
|
813
|
+
otherHelloLynkId: safeHelloLynkId(
|
|
814
|
+
other.hellolynkId,
|
|
815
|
+
"neutral_host_workbench_relationship_peer_invalid",
|
|
816
|
+
),
|
|
817
|
+
otherLabel: optionalText(
|
|
818
|
+
other.displayName,
|
|
819
|
+
160,
|
|
820
|
+
"neutral_host_workbench_relationship_peer_invalid",
|
|
821
|
+
),
|
|
822
|
+
status,
|
|
823
|
+
createdAt: isoTimestamp(
|
|
824
|
+
item.createdAt,
|
|
825
|
+
"neutral_host_workbench_relationship_time_invalid",
|
|
826
|
+
),
|
|
827
|
+
updatedAt: isoTimestamp(
|
|
828
|
+
item.updatedAt,
|
|
829
|
+
"neutral_host_workbench_relationship_time_invalid",
|
|
830
|
+
),
|
|
831
|
+
endedAt,
|
|
832
|
+
};
|
|
833
|
+
});
|
|
834
|
+
relationships.sort((left, right) => compareText(right.updatedAt, left.updatedAt)
|
|
835
|
+
|| compareText(left.relationshipId, right.relationshipId));
|
|
836
|
+
assertUnique(
|
|
837
|
+
relationships,
|
|
838
|
+
(item) => item.relationshipId,
|
|
839
|
+
"neutral_host_workbench_relationship_conflict",
|
|
840
|
+
);
|
|
841
|
+
return relationships;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function normalizeBlocks(value) {
|
|
845
|
+
const blocks = array(value, "neutral_host_workbench_blocks_invalid").map((item) => {
|
|
846
|
+
record(item, "neutral_host_workbench_block_invalid");
|
|
847
|
+
const other = record(item.otherPrincipal, "neutral_host_workbench_block_invalid");
|
|
848
|
+
if (item.status !== "active") fail("neutral_host_workbench_block_status_invalid");
|
|
849
|
+
return {
|
|
850
|
+
blockId: safeId(item.blockId, "neutral_host_workbench_block_id_invalid"),
|
|
851
|
+
otherHelloLynkId: safeHelloLynkId(
|
|
852
|
+
other.hellolynkId,
|
|
853
|
+
"neutral_host_workbench_block_peer_invalid",
|
|
854
|
+
),
|
|
855
|
+
otherLabel: optionalText(
|
|
856
|
+
other.displayName,
|
|
857
|
+
160,
|
|
858
|
+
"neutral_host_workbench_block_peer_invalid",
|
|
859
|
+
),
|
|
860
|
+
status: "active",
|
|
861
|
+
createdAt: isoTimestamp(item.createdAt, "neutral_host_workbench_block_time_invalid"),
|
|
862
|
+
};
|
|
863
|
+
});
|
|
864
|
+
blocks.sort((left, right) => compareText(right.createdAt, left.createdAt)
|
|
865
|
+
|| compareText(left.blockId, right.blockId));
|
|
866
|
+
assertUnique(blocks, (item) => item.blockId, "neutral_host_workbench_block_conflict");
|
|
867
|
+
return blocks;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function normalizeOutbox(value, subject) {
|
|
871
|
+
const rawByMessageKey = new Map();
|
|
872
|
+
for (const item of array(value, "neutral_host_workbench_outbox_invalid")) {
|
|
873
|
+
if (!item || typeof item !== "object" || Array.isArray(item)
|
|
874
|
+
|| (item.direction ?? "outgoing") !== "outgoing") continue;
|
|
875
|
+
if (item.senderPrincipalId !== subject.principalId) continue;
|
|
876
|
+
const localMessageId = safeId(
|
|
877
|
+
item.localMessageId ?? item.id,
|
|
878
|
+
"neutral_host_workbench_message_id_invalid",
|
|
879
|
+
);
|
|
880
|
+
const clientMessageId = safeId(
|
|
881
|
+
item.clientMessageId ?? item.id,
|
|
882
|
+
"neutral_host_workbench_message_id_invalid",
|
|
883
|
+
);
|
|
884
|
+
rawByMessageKey.set(`${localMessageId}\0${clientMessageId}`, item);
|
|
885
|
+
}
|
|
886
|
+
const outbox = normalizeMessages(value, subject, "outbox").map((message) => {
|
|
887
|
+
const raw = rawByMessageKey.get(`${message.localMessageId}\0${message.clientMessageId}`);
|
|
888
|
+
if (!raw) fail("neutral_host_workbench_outbox_invalid");
|
|
889
|
+
const status = message.status;
|
|
890
|
+
const unknown = status === "delivery_state_unknown" || status === "unknown";
|
|
891
|
+
const retryAllowed = !unknown
|
|
892
|
+
&& message.conversationBinding != null
|
|
893
|
+
&& message.conversationBinding.localDelegateId === subject.delegateId
|
|
894
|
+
&& MANUAL_RETRYABLE_OUTBOX_STATUSES.has(status)
|
|
895
|
+
&& raw.senderPrincipalId === subject.principalId
|
|
896
|
+
&& raw.senderDelegateId === subject.delegateId
|
|
897
|
+
&& raw.lastErrorCode !== "reply_target_deleted"
|
|
898
|
+
&& raw.retryAllowed !== false;
|
|
899
|
+
return {
|
|
900
|
+
clientMessageId: message.clientMessageId,
|
|
901
|
+
threadId: messageThreadId(subject, message),
|
|
902
|
+
recipientHelloLynkId: message.peerHelloLynkId,
|
|
903
|
+
localDelegateId: message.conversationBinding?.localDelegateId ?? null,
|
|
904
|
+
recipientDelegateId: message.conversationBinding?.peerDelegateId ?? null,
|
|
905
|
+
text: message.text,
|
|
906
|
+
status,
|
|
907
|
+
retryAllowed,
|
|
908
|
+
errorCategory: raw.lastErrorCode === "reply_target_deleted" && raw.resendClientMessageId
|
|
909
|
+
? "reply_target_deleted_resent" : redactErrorCategory(raw?.errorCategory ?? raw?.lastErrorCode, status),
|
|
910
|
+
createdAt: message.createdAt,
|
|
911
|
+
updatedAt: message.updatedAt,
|
|
912
|
+
};
|
|
913
|
+
});
|
|
914
|
+
outbox.sort((left, right) => compareText(right.updatedAt, left.updatedAt)
|
|
915
|
+
|| compareText(left.clientMessageId, right.clientMessageId));
|
|
916
|
+
assertUnique(outbox, (item) => item.clientMessageId, "neutral_host_workbench_outbox_conflict");
|
|
917
|
+
return outbox;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function buildThreads(messages, contactByHelloLynkId, subject) {
|
|
921
|
+
const threadMap = new Map();
|
|
922
|
+
const fallbackLabels = new Map();
|
|
923
|
+
const projectedMessages = [];
|
|
924
|
+
for (const message of messages) {
|
|
925
|
+
const binding = message.conversationBinding;
|
|
926
|
+
const threadId = messageThreadId(subject, message);
|
|
927
|
+
if (message.senderDelegateFallbackLabel != null) {
|
|
928
|
+
fallbackLabels.set(threadId, message.senderDelegateFallbackLabel);
|
|
929
|
+
}
|
|
930
|
+
const candidateContact = message.peerHelloLynkId == null ? null : contactByHelloLynkId.get(message.peerHelloLynkId) ?? null;
|
|
931
|
+
const contact = binding && candidateContact?.principalId != null
|
|
932
|
+
&& candidateContact.principalId !== binding.peerPrincipalId ? null : candidateContact;
|
|
933
|
+
const instance = binding == null ? null : contact?.instances.find((item) => item.delegateId === binding.peerDelegateId);
|
|
934
|
+
let thread = threadMap.get(threadId);
|
|
935
|
+
if (!thread) {
|
|
936
|
+
thread = {
|
|
937
|
+
threadId,
|
|
938
|
+
conversationKind: binding == null ? "legacy" : "instance",
|
|
939
|
+
localDelegateId: binding?.localDelegateId ?? null,
|
|
940
|
+
peerDelegateId: binding?.peerDelegateId ?? null,
|
|
941
|
+
contactId: contact?.contactId ?? null,
|
|
942
|
+
peerPrincipalId: message.peerPrincipalId,
|
|
943
|
+
peerHelloLynkId: message.peerHelloLynkId,
|
|
944
|
+
displayName: contact?.displayName
|
|
945
|
+
?? (message.direction === "incoming" ? message.senderPrincipalDisplayName : null)
|
|
946
|
+
?? message.peerHelloLynkId
|
|
947
|
+
?? message.peerPrincipalId,
|
|
948
|
+
delegateLabel: instance?.displayName ?? (message.direction === "incoming" ? message.senderDelegateDisplayName : null),
|
|
949
|
+
lastMessage: null,
|
|
950
|
+
unreadCount: 0,
|
|
951
|
+
updatedAt: message.updatedAt,
|
|
952
|
+
};
|
|
953
|
+
threadMap.set(threadId, thread);
|
|
954
|
+
} else {
|
|
955
|
+
mergePeerIdentity(thread, message);
|
|
956
|
+
if (thread.contactId == null && contact) {
|
|
957
|
+
thread.contactId = contact.contactId;
|
|
958
|
+
thread.displayName = contact.displayName;
|
|
959
|
+
}
|
|
960
|
+
if (thread.delegateLabel == null && message.direction === "incoming" && message.senderDelegateDisplayName != null) {
|
|
961
|
+
thread.delegateLabel = message.senderDelegateDisplayName;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (message.direction === "incoming" && message.unread) thread.unreadCount += 1;
|
|
965
|
+
const candidate = {
|
|
966
|
+
text: message.text,
|
|
967
|
+
direction: message.direction,
|
|
968
|
+
status: message.status,
|
|
969
|
+
createdAt: message.createdAt,
|
|
970
|
+
};
|
|
971
|
+
if (!thread.lastMessage
|
|
972
|
+
|| compareText(candidate.createdAt, thread.lastMessage.createdAt) > 0
|
|
973
|
+
|| (candidate.createdAt === thread.lastMessage.createdAt
|
|
974
|
+
&& compareText(message.localMessageId, thread.lastMessageId) > 0)) {
|
|
975
|
+
thread.lastMessage = candidate;
|
|
976
|
+
thread.lastMessageId = message.localMessageId;
|
|
977
|
+
}
|
|
978
|
+
if (compareText(message.updatedAt, thread.updatedAt) > 0) thread.updatedAt = message.updatedAt;
|
|
979
|
+
projectedMessages.push({
|
|
980
|
+
localMessageId: message.localMessageId,
|
|
981
|
+
clientMessageId: message.clientMessageId,
|
|
982
|
+
threadId,
|
|
983
|
+
direction: message.direction,
|
|
984
|
+
senderPrincipalId: message.senderPrincipalId,
|
|
985
|
+
senderDelegateId: message.senderDelegateId,
|
|
986
|
+
intentSource: message.intentSource,
|
|
987
|
+
authorizationScope: message.authorizationScope,
|
|
988
|
+
text: message.text,
|
|
989
|
+
status: message.status,
|
|
990
|
+
...(message.automaticDeliveryState === "expired" ? { automaticDeliveryState: "expired" } : {}),
|
|
991
|
+
unread: message.unread,
|
|
992
|
+
createdAt: message.createdAt,
|
|
993
|
+
updatedAt: message.updatedAt,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
// Saved target instances are selectable before the first message. This
|
|
997
|
+
// explicit current selection never supplies missing identities to old rows.
|
|
998
|
+
for (const contact of contactByHelloLynkId.values()) {
|
|
999
|
+
if (contact.principalId == null) continue;
|
|
1000
|
+
const region = subject.region === "international" ? "intl" : subject.region;
|
|
1001
|
+
if (contact.hellolynkId.split(":")[1] !== region) continue;
|
|
1002
|
+
for (const instance of contact.instances) {
|
|
1003
|
+
const binding = normalizeInstanceConversationBinding({
|
|
1004
|
+
schemaVersion: 1,
|
|
1005
|
+
region,
|
|
1006
|
+
localPrincipalId: subject.principalId,
|
|
1007
|
+
localDelegateId: subject.delegateId,
|
|
1008
|
+
peerPrincipalId: contact.principalId,
|
|
1009
|
+
peerDelegateId: instance.delegateId,
|
|
1010
|
+
}, { region: subject.region, principalId: subject.principalId });
|
|
1011
|
+
const threadId = instanceConversationKey(binding);
|
|
1012
|
+
if (threadMap.has(threadId)) continue;
|
|
1013
|
+
threadMap.set(threadId, {
|
|
1014
|
+
threadId,
|
|
1015
|
+
conversationKind: "instance",
|
|
1016
|
+
localDelegateId: binding.localDelegateId,
|
|
1017
|
+
peerDelegateId: binding.peerDelegateId,
|
|
1018
|
+
contactId: contact.contactId,
|
|
1019
|
+
peerPrincipalId: contact.principalId,
|
|
1020
|
+
peerHelloLynkId: contact.hellolynkId,
|
|
1021
|
+
displayName: contact.displayName,
|
|
1022
|
+
delegateLabel: instance.displayName,
|
|
1023
|
+
lastMessage: null,
|
|
1024
|
+
unreadCount: 0,
|
|
1025
|
+
updatedAt: instance.updatedAt,
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const threads = [...threadMap.values()].map(({ lastMessageId: _ignored, ...thread }) => ({
|
|
1030
|
+
...thread,
|
|
1031
|
+
// Apply local display metadata only after inspecting all stored messages,
|
|
1032
|
+
// so an earlier missing label never overrides a later sender-provided name.
|
|
1033
|
+
delegateLabel: thread.delegateLabel ?? fallbackLabels.get(thread.threadId) ?? null,
|
|
1034
|
+
}));
|
|
1035
|
+
threads.sort((left, right) => compareText(right.updatedAt, left.updatedAt)
|
|
1036
|
+
|| compareText(left.threadId, right.threadId));
|
|
1037
|
+
projectedMessages.sort((left, right) => compareText(left.createdAt, right.createdAt)
|
|
1038
|
+
|| compareText(left.localMessageId, right.localMessageId));
|
|
1039
|
+
return { threads, messages: projectedMessages };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function mergePeerIdentity(thread, message) {
|
|
1043
|
+
if (thread.peerPrincipalId != null && message.peerPrincipalId != null
|
|
1044
|
+
&& thread.peerPrincipalId !== message.peerPrincipalId) {
|
|
1045
|
+
fail("neutral_host_workbench_thread_identity_conflict");
|
|
1046
|
+
}
|
|
1047
|
+
if (thread.peerHelloLynkId != null && message.peerHelloLynkId != null
|
|
1048
|
+
&& thread.peerHelloLynkId !== message.peerHelloLynkId) {
|
|
1049
|
+
fail("neutral_host_workbench_thread_identity_conflict");
|
|
1050
|
+
}
|
|
1051
|
+
thread.peerPrincipalId ??= message.peerPrincipalId;
|
|
1052
|
+
thread.peerHelloLynkId ??= message.peerHelloLynkId;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function stableThreadId(subject, peerPrincipalId, peerHelloLynkId) {
|
|
1056
|
+
requirePeer(peerPrincipalId, peerHelloLynkId);
|
|
1057
|
+
const peerKey = peerHelloLynkId == null ? `principal:${peerPrincipalId}` : `hellolynk:${peerHelloLynkId}`;
|
|
1058
|
+
const region = subject.region === "international" ? "intl" : subject.region;
|
|
1059
|
+
return `thread_${digest(`${region}\0${subject.principalId}\0${peerKey}`).slice(0, 40)}`;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function messageThreadId(subject, message) {
|
|
1063
|
+
return message.conversationBinding == null
|
|
1064
|
+
? stableThreadId(subject, message.peerPrincipalId, message.peerHelloLynkId)
|
|
1065
|
+
: instanceConversationKey(message.conversationBinding);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function filterThreads(threads, messages, query, filter, indexedMatches = null) {
|
|
1069
|
+
const tokens = query.split(/\s+/u).filter(Boolean);
|
|
1070
|
+
const textByThread = new Map();
|
|
1071
|
+
for (const message of messages) {
|
|
1072
|
+
const values = textByThread.get(message.threadId) ?? [];
|
|
1073
|
+
values.push(message.text);
|
|
1074
|
+
textByThread.set(message.threadId, values);
|
|
1075
|
+
}
|
|
1076
|
+
return threads.filter((thread) => {
|
|
1077
|
+
if (filter === "archived" ? thread.archived !== true : thread.archived === true) return false;
|
|
1078
|
+
if (filter === "unread" && thread.unreadCount === 0) return false;
|
|
1079
|
+
if (tokens.length === 0) return true;
|
|
1080
|
+
if (indexedMatches?.has(thread.threadId)) return true;
|
|
1081
|
+
const haystack = normalizeSearchText([
|
|
1082
|
+
thread.displayName,
|
|
1083
|
+
thread.peerPrincipalId ?? "",
|
|
1084
|
+
thread.peerHelloLynkId ?? "",
|
|
1085
|
+
thread.delegateLabel ?? "",
|
|
1086
|
+
thread.lastMessage?.text ?? "",
|
|
1087
|
+
...(textByThread.get(thread.threadId) ?? []),
|
|
1088
|
+
].join("\n"));
|
|
1089
|
+
return tokens.every((token) => haystack.includes(token));
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function encodeIndexedMessageCursor(direction, message, fingerprint) {
|
|
1094
|
+
const anchor = Buffer.from(JSON.stringify({ createdAt: message.createdAt,
|
|
1095
|
+
localMessageId: message.localMessageId }), "utf8").toString("base64url");
|
|
1096
|
+
return `wbc3.m.${direction}.${anchor}.${fingerprint}`;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function decodeIndexedMessageCursor(value, fingerprint) {
|
|
1100
|
+
if (value == null) return null;
|
|
1101
|
+
const match = /^wbc3\.m\.(before|after|from|older-window|newer-window)\.([A-Za-z0-9_-]{8,512})\.([a-f0-9]{24})$/.exec(value);
|
|
1102
|
+
if (!match || match[3] !== fingerprint) fail("neutral_host_workbench_cursor_invalid");
|
|
1103
|
+
let anchor;
|
|
1104
|
+
try { anchor = JSON.parse(Buffer.from(match[2], "base64url").toString("utf8")); }
|
|
1105
|
+
catch { fail("neutral_host_workbench_cursor_invalid"); }
|
|
1106
|
+
exactRecord(anchor, ["createdAt", "localMessageId"], "neutral_host_workbench_cursor_invalid");
|
|
1107
|
+
return { direction: match[1], anchor: {
|
|
1108
|
+
createdAt: isoTimestamp(anchor.createdAt, "neutral_host_workbench_cursor_invalid"),
|
|
1109
|
+
localMessageId: safeId(anchor.localMessageId, "neutral_host_workbench_cursor_invalid"),
|
|
1110
|
+
} };
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function normalizeQuery(value) {
|
|
1114
|
+
if (typeof value !== "string" || value.length > HELLOLYNK_WORKBENCH_MAX_QUERY_LENGTH || UNSAFE_TEXT.test(value)) {
|
|
1115
|
+
fail("neutral_host_workbench_query_invalid");
|
|
1116
|
+
}
|
|
1117
|
+
return normalizeSearchText(value).replace(/\s+/gu, " ");
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function normalizeSearchText(value) {
|
|
1121
|
+
return String(value).normalize("NFKC").trim().toLocaleLowerCase("und");
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function encodeCursor(kind, offset, fingerprint) {
|
|
1125
|
+
return `wbc1.${kind}.${offset}.${fingerprint}`;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function decodeCursor(value, kind, fingerprint, maximum) {
|
|
1129
|
+
if (value == null) return 0;
|
|
1130
|
+
const match = /^wbc1\.([tm])\.(0|[1-9][0-9]{0,8})\.([a-f0-9]{24})$/.exec(value);
|
|
1131
|
+
if (!match || match[1] !== kind || match[3] !== fingerprint) {
|
|
1132
|
+
fail("neutral_host_workbench_cursor_invalid");
|
|
1133
|
+
}
|
|
1134
|
+
const offset = Number(match[2]);
|
|
1135
|
+
if (!Number.isSafeInteger(offset) || offset <= 0 || offset >= maximum) {
|
|
1136
|
+
fail("neutral_host_workbench_cursor_invalid");
|
|
1137
|
+
}
|
|
1138
|
+
return offset;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function cursorFingerprint(value) {
|
|
1142
|
+
return digest(stableJson(value)).slice(0, 24);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function normalizeCapabilities(value) {
|
|
1146
|
+
const input = value == null ? DEFAULT_CAPABILITIES : array(value, "neutral_host_workbench_capabilities_invalid");
|
|
1147
|
+
const normalized = [...new Set(input.map((item) => {
|
|
1148
|
+
if (typeof item !== "string" || !CAPABILITIES.has(item)) {
|
|
1149
|
+
fail("neutral_host_workbench_capability_invalid");
|
|
1150
|
+
}
|
|
1151
|
+
return item;
|
|
1152
|
+
}))].sort();
|
|
1153
|
+
return deepFreeze(normalized);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function normalizeConnectedClients(value) {
|
|
1157
|
+
const clients = array(value, "neutral_host_workbench_clients_invalid").map((item) => {
|
|
1158
|
+
exactRecord(
|
|
1159
|
+
item,
|
|
1160
|
+
["clientId", "clientType", "state"],
|
|
1161
|
+
"neutral_host_workbench_client_invalid",
|
|
1162
|
+
);
|
|
1163
|
+
return {
|
|
1164
|
+
clientId: safeId(item.clientId, "neutral_host_workbench_client_invalid"),
|
|
1165
|
+
clientType: oneOf(item.clientType, CLIENT_TYPES, "neutral_host_workbench_client_invalid"),
|
|
1166
|
+
state: oneOf(item.state, CLIENT_STATES, "neutral_host_workbench_client_invalid"),
|
|
1167
|
+
};
|
|
1168
|
+
});
|
|
1169
|
+
clients.sort((left, right) => compareText(left.clientId, right.clientId));
|
|
1170
|
+
assertUnique(clients, (item) => item.clientId, "neutral_host_workbench_client_conflict");
|
|
1171
|
+
return clients;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function normalizeEventTypes(value) {
|
|
1175
|
+
const result = [...new Set(array(value, "neutral_host_workbench_event_types_invalid")
|
|
1176
|
+
.map((item) => errorCode(item, "neutral_host_workbench_event_type_invalid")))].sort();
|
|
1177
|
+
if (result.length > 64) fail("neutral_host_workbench_event_types_invalid");
|
|
1178
|
+
return result;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function normalizeChangeCursor(value) {
|
|
1182
|
+
const result = String(value ?? "0");
|
|
1183
|
+
if (!SAFE_REVISION.test(result)) fail("neutral_host_workbench_change_cursor_invalid");
|
|
1184
|
+
return result;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function deterministicGeneratedAt(baseline, ...collections) {
|
|
1188
|
+
let latest = baseline;
|
|
1189
|
+
for (const collection of collections) {
|
|
1190
|
+
for (const item of collection) {
|
|
1191
|
+
const candidate = item.updatedAt ?? item.receivedAt ?? item.createdAt;
|
|
1192
|
+
if (candidate != null && compareText(candidate, latest) > 0) latest = candidate;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return latest;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function redactErrorCategory(value, status) {
|
|
1199
|
+
if (value === "reply_target_deleted") return "reply_target_deleted";
|
|
1200
|
+
if (["e2ee_envelope_expired", "e2ee_created_at_in_future"].includes(value)) return "message_time";
|
|
1201
|
+
if (value === "e2ee_peer_identity_pin_mismatch") return "device_identity_changed";
|
|
1202
|
+
if (value === "e2ee_recipient_external_contact_disabled") return "external_contact_disabled";
|
|
1203
|
+
const normalized = `${String(value || "")} ${status}`.trim().toLocaleLowerCase("und");
|
|
1204
|
+
if (!normalized) return null;
|
|
1205
|
+
if (/e2ee_request_timeout|e2ee_transport_error/u.test(normalized)) return "connectivity";
|
|
1206
|
+
if (/e2ee|encrypt|decrypt|key/u.test(normalized)) return "encryption";
|
|
1207
|
+
if (/auth|scope|permission|forbid/u.test(normalized)) return "authorization";
|
|
1208
|
+
if (/rate|thrott/u.test(normalized)) return "rate_limit";
|
|
1209
|
+
if (/offline|network|timeout|unavailable|connect/u.test(normalized)) return "connectivity";
|
|
1210
|
+
if (/input|invalid|payload|recipient|not_found/u.test(normalized)) return "validation";
|
|
1211
|
+
return "delivery";
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function requirePeer(principalId, hellolynkId) {
|
|
1215
|
+
if (principalId == null && hellolynkId == null) fail("neutral_host_workbench_peer_identity_missing");
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function optionalSafeId(value) {
|
|
1219
|
+
return value == null ? null : safeId(value, "neutral_host_workbench_id_invalid");
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function normalizeMessageProvenance(authorizationScope, intentSource) {
|
|
1223
|
+
if (authorizationScope == null && intentSource == null) {
|
|
1224
|
+
return { authorizationScope: null, intentSource: null };
|
|
1225
|
+
}
|
|
1226
|
+
if (
|
|
1227
|
+
authorizationScope !== MESSAGE_AUTHORIZATION_SCOPE
|
|
1228
|
+
|| !MESSAGE_INTENT_SOURCES.has(intentSource)
|
|
1229
|
+
) fail("neutral_host_workbench_message_provenance_invalid");
|
|
1230
|
+
return { authorizationScope, intentSource };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function optionalHelloLynkId(value) {
|
|
1234
|
+
return value == null ? null : safeHelloLynkId(value, "neutral_host_workbench_hellolynk_id_invalid");
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function safeId(value, code) {
|
|
1238
|
+
if (typeof value !== "string" || !SAFE_ID.test(value)) fail(code);
|
|
1239
|
+
return value;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function safeRevision(value, code) {
|
|
1243
|
+
if (typeof value !== "string" || !SAFE_REVISION.test(value)) fail(code);
|
|
1244
|
+
return value;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function safeMessageCursor(value) {
|
|
1248
|
+
if (typeof value !== "string" || value.length > 800 || !/^[A-Za-z0-9._-]+$/.test(value)) {
|
|
1249
|
+
fail("neutral_host_workbench_message_cursor_invalid");
|
|
1250
|
+
}
|
|
1251
|
+
return value;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function contactRequestCursor(value, code) {
|
|
1255
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{20,512}$/.test(value)) fail(code);
|
|
1256
|
+
return value;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function safeHelloLynkId(value, code) {
|
|
1260
|
+
if (typeof value !== "string" || !HELLOLYNK_ID.test(value)) fail(code);
|
|
1261
|
+
return value;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function text(value, maximum, code, emptyAllowed = false) {
|
|
1265
|
+
if (typeof value !== "string" || (!emptyAllowed && !value)
|
|
1266
|
+
|| [...value].length > maximum || value.includes("\u0000")) {
|
|
1267
|
+
fail(code);
|
|
1268
|
+
}
|
|
1269
|
+
return value;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function optionalText(value, maximum, code) {
|
|
1273
|
+
return value == null ? null : text(value, maximum, code);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function errorCode(value, code) {
|
|
1277
|
+
const normalized = String(value || "");
|
|
1278
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,126}$/.test(normalized)) fail(code);
|
|
1279
|
+
return normalized;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function isoTimestamp(value, code) {
|
|
1283
|
+
const time = value instanceof Date ? value.getTime() : Date.parse(String(value || ""));
|
|
1284
|
+
if (!Number.isFinite(time)) fail(code);
|
|
1285
|
+
return new Date(time).toISOString();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function exactRecord(value, allowedKeys, code) {
|
|
1289
|
+
record(value, code);
|
|
1290
|
+
const keys = Object.keys(value).sort();
|
|
1291
|
+
const accepted = [...allowedKeys].sort();
|
|
1292
|
+
if (keys.some((key) => !accepted.includes(key))) fail(code);
|
|
1293
|
+
return value;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function record(value, code) {
|
|
1297
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail(code);
|
|
1298
|
+
return value;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function array(value, code) {
|
|
1302
|
+
if (!Array.isArray(value)) fail(code);
|
|
1303
|
+
return value;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function oneOf(value, allowed, code) {
|
|
1307
|
+
if (!allowed.has(value)) fail(code);
|
|
1308
|
+
return value;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function assertUnique(items, keyOf, code) {
|
|
1312
|
+
const seen = new Set();
|
|
1313
|
+
for (const item of items) {
|
|
1314
|
+
const key = keyOf(item);
|
|
1315
|
+
if (seen.has(key)) fail(code);
|
|
1316
|
+
seen.add(key);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function assertNoSensitiveKeys(value, seen = new Set(), path = []) {
|
|
1321
|
+
if (!value || typeof value !== "object" || seen.has(value)) return;
|
|
1322
|
+
seen.add(value);
|
|
1323
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
1324
|
+
const normalized = String(key).toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1325
|
+
const exactMessageAuthorizationScope = key === "authorizationScope"
|
|
1326
|
+
&& path.length === 3
|
|
1327
|
+
&& path[0] === "messages"
|
|
1328
|
+
&& path[1] === "items"
|
|
1329
|
+
&& /^(0|[1-9][0-9]*)$/.test(path[2])
|
|
1330
|
+
&& (nested == null || nested === MESSAGE_AUTHORIZATION_SCOPE);
|
|
1331
|
+
if (!exactMessageAuthorizationScope && (
|
|
1332
|
+
(normalized.startsWith("authorization") && normalized !== "authorizationversion")
|
|
1333
|
+
|| /(bearer|cookie|password|privatekey|secret|token|credential)/u.test(normalized)
|
|
1334
|
+
)) {
|
|
1335
|
+
fail("neutral_host_workbench_sensitive_field_forbidden");
|
|
1336
|
+
}
|
|
1337
|
+
assertNoSensitiveKeys(nested, seen, [...path, key]);
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function stableJson(value) {
|
|
1342
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
1343
|
+
if (value && typeof value === "object") {
|
|
1344
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
1345
|
+
}
|
|
1346
|
+
return JSON.stringify(value);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function digest(value) {
|
|
1350
|
+
return crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function compareText(left, right) {
|
|
1354
|
+
return String(left).localeCompare(String(right), "und");
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function deepFreeze(value) {
|
|
1358
|
+
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
1359
|
+
for (const nested of Object.values(value)) deepFreeze(nested);
|
|
1360
|
+
return Object.freeze(value);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function fail(code) {
|
|
1364
|
+
throw new HelloLynkNeutralHostWorkbenchReadModelError(code);
|
|
1365
|
+
}
|