@zieglar/pi-chat-exchange 0.0.0-stage → 0.2.0
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/CHANGELOG.md +70 -0
- package/LICENSE +21 -0
- package/README.md +89 -2
- package/dist/bin/mcp-stdio.js +196 -0
- package/dist/control/debug-log.js +67 -0
- package/dist/control/mcp/debug-transport.js +48 -0
- package/dist/control/mcp/messaging-tools.js +278 -0
- package/dist/control/mcp/ping.js +43 -0
- package/dist/control/mcp/server.js +153 -0
- package/dist/core/authorization.js +32 -0
- package/dist/core/reference.js +32 -0
- package/dist/core/rules.js +468 -0
- package/dist/endpoints/pi/contract.js +2 -0
- package/dist/endpoints/pi/session-host.js +39 -0
- package/dist/host/bound-endpoint.js +176 -0
- package/dist/host/endpoint-host.js +839 -0
- package/dist/host/mcp-binding-config.js +209 -0
- package/dist/host/ownership-recovery.js +257 -0
- package/dist/index.js +11 -0
- package/dist/protocol/capabilities.js +46 -0
- package/dist/protocol/endpoint-capabilities.js +15 -0
- package/dist/protocol/entities.js +185 -0
- package/dist/protocol/formats.js +13 -0
- package/dist/protocol/idempotency.js +93 -0
- package/dist/protocol/identity.js +130 -0
- package/dist/protocol/index.js +8 -0
- package/dist/protocol/lifecycle.js +125 -0
- package/dist/protocol/outcomes.js +55 -0
- package/dist/runtime/desktop-manifest.js +65 -0
- package/dist/runtime/diagnostics.js +34 -0
- package/dist/runtime/executables.js +222 -0
- package/dist/storage/capability.js +221 -0
- package/dist/storage/connection.js +94 -0
- package/dist/storage/creation-request.js +176 -0
- package/dist/storage/creation.js +553 -0
- package/dist/storage/current-schema.js +397 -0
- package/dist/storage/delivery-ack.js +177 -0
- package/dist/storage/errors.js +9 -0
- package/dist/storage/filesystem.js +116 -0
- package/dist/storage/inbox-discovery.js +135 -0
- package/dist/storage/messaging.js +156 -0
- package/dist/storage/migration.js +258 -0
- package/dist/storage/ownership.js +789 -0
- package/dist/storage/participant-identity.js +127 -0
- package/dist/storage/participant.js +700 -0
- package/dist/storage/recovery.js +99 -0
- package/dist/storage/runtime.js +113 -0
- package/dist/storage/schema.js +556 -0
- package/dist/storage/session-state.js +75 -0
- package/dist/storage/store.js +450 -0
- package/dist/storage/transaction.js +199 -0
- package/dist/storage/trust-root.js +164 -0
- package/dist/tunnel/background-manager.js +1727 -0
- package/dist/tunnel/codes.js +79 -0
- package/dist/tunnel/health.js +75 -0
- package/dist/tunnel/keychain.js +526 -0
- package/dist/tunnel/managed-binding.js +454 -0
- package/dist/tunnel/manager-panel.js +249 -0
- package/dist/tunnel/manager-ui.js +341 -0
- package/dist/tunnel/manager.js +372 -0
- package/dist/tunnel/messaging-activation-ui.js +85 -0
- package/dist/tunnel/messaging-config.js +246 -0
- package/dist/tunnel/multi-session-admission.js +31 -0
- package/dist/tunnel/multi-session-policy.js +314 -0
- package/dist/tunnel/offline-client.js +104 -0
- package/dist/tunnel/profile.js +319 -0
- package/dist/tunnel/redact.js +9 -0
- package/dist/tunnel/secure-input.js +318 -0
- package/dist/tunnel/session-process-mutex.js +338 -0
- package/dist/tunnel/setup-ui.js +443 -0
- package/dist/tunnel/setup.js +734 -0
- package/dist/tunnel/state.js +127 -0
- package/docs/architecture.md +170 -0
- package/docs/chatgpt-installation.md +149 -0
- package/docs/delivery-ack-contract.md +86 -0
- package/docs/independent-install.md +156 -0
- package/docs/multi-session-usage.md +77 -0
- package/docs/ownership-contract.md +429 -0
- package/docs/protocol-v2.md +269 -0
- package/docs/releases/0.2.0.md +59 -0
- package/docs/storage-contract.md +1241 -0
- package/docs/storage-security.md +138 -0
- package/docs/tunnel-manager-background.md +169 -0
- package/docs/tunnel-setup.md +214 -0
- package/native/tunnel-keychain.swift +432 -0
- package/package.json +77 -4
- package/pi-extension/README.md +125 -0
- package/pi-extension/allowlist.example.json +11 -0
- package/pi-extension/index.ts +2312 -0
- package/pi-extension/multi-session-commands.ts +93 -0
- package/pi-extension/multi-session-runtime.ts +75 -0
- package/pi-extension/tui-keys.ts +40 -0
- package/scripts/reconcile-session-process-mutex.mjs +262 -0
- package/scripts/recover-stale-ownership.mjs +91 -0
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CE-0002 shared Endpoint Host (HANDOFF-853274, extracted from the B3-P3B
|
|
3
|
+
* Pi host seam without changing its authority chain).
|
|
4
|
+
*
|
|
5
|
+
* This module is the ONLY host-side composition layer that endpoint and
|
|
6
|
+
* control integrations reach through. It imports no Pi SDK, no MCP SDK, no
|
|
7
|
+
* control surface, no endpoint integration, and never reads the process
|
|
8
|
+
* environment, so it is verified against plain data in and plain data out.
|
|
9
|
+
*
|
|
10
|
+
* Trust boundary:
|
|
11
|
+
* - The operator-controlled allowlist is the only source of Endpoint
|
|
12
|
+
* eligibility. Model/tool arguments can never register a Participant, issue
|
|
13
|
+
* a capability, or change ownership: those operations happen only through
|
|
14
|
+
* host policy (`beginSession`) or validated mapping recovery (`resume`).
|
|
15
|
+
* - Cross-adapter recipients exist only when the operator lists them
|
|
16
|
+
* explicitly in `recipients`; a recipient label grants send eligibility
|
|
17
|
+
* toward that EndpointRef and nothing else (no read, no registration).
|
|
18
|
+
* - Capability plaintext exists only in this process's memory table and is
|
|
19
|
+
* never returned, logged, persisted, or placed in any mapping payload.
|
|
20
|
+
* - Host-secret access stays inside the trusted storage plane; this adapter
|
|
21
|
+
* never reads the secret file itself — `issueCapability` enforces the
|
|
22
|
+
* trust-root contract in its own owned transaction.
|
|
23
|
+
* - P3A authority checks (exact Session-bound capability, ATTACHED Session,
|
|
24
|
+
* current Owner, monotonic fencing, active lease, membership, immutable
|
|
25
|
+
* endpoint binding, authority-before-replay) are preserved untouched; this
|
|
26
|
+
* adapter only delegates and maps refusals to sanitized codes.
|
|
27
|
+
* - Lease lifecycle (`renew`, `end`) is explicit and opt-in: a caller that
|
|
28
|
+
* never invokes it (the Pi extension) observes the original behavior.
|
|
29
|
+
* Renewal never revives an expired lease; `end` releases ownership and then
|
|
30
|
+
* detaches the Session with the Session's own `resume` capability.
|
|
31
|
+
* - This host never performs takeover recovery: an expired or superseded lease
|
|
32
|
+
* is refused as `CE_HOST_AUTHORITY_ABSENT`. The separate operator-only caller
|
|
33
|
+
* for CE-TAKEOVER-RECOVERY-CALLER-001 is not reachable through this adapter.
|
|
34
|
+
* Restart never implies ownership recovery.
|
|
35
|
+
*/
|
|
36
|
+
import { EXCHANGE_ID_PATTERN, MESSAGE_ID_PATTERN } from "../protocol/identity.js";
|
|
37
|
+
import { SHA256_HEX_PATTERN } from "../protocol/entities.js";
|
|
38
|
+
import { recordDeliveryAck } from "../storage/delivery-ack.js";
|
|
39
|
+
import { CreationDomainError } from "../storage/creation.js";
|
|
40
|
+
import { createExchange } from "../storage/creation.js";
|
|
41
|
+
import { CreationRequestError } from "../storage/creation-request.js";
|
|
42
|
+
import { StoreLifecycleError } from "../storage/errors.js";
|
|
43
|
+
import { readMessageMetadata, readMessagePayload, sendMessage, } from "../storage/messaging.js";
|
|
44
|
+
import { discoverInboxMessages } from "../storage/inbox-discovery.js";
|
|
45
|
+
import { captureFields, isParticipantId, isSessionId, ParticipantRequestError, } from "../storage/participant-identity.js";
|
|
46
|
+
import { createParticipant, ParticipantDomainError, issueCapability, joinExchange, readParticipantByEndpoint, } from "../storage/participant.js";
|
|
47
|
+
import { acquireOwnership, bindSession, detachSession, generateSessionId, LEASE_DEFAULT_TTL_SECONDS, LEASE_MAX_TTL_SECONDS, LEASE_MIN_TTL_SECONDS, readCurrentOwnership, readLeaseStatus, readSessionLifecycle, releaseOwnership, renewLease, } from "../storage/ownership.js";
|
|
48
|
+
import { StorageTransactionError } from "../storage/transaction.js";
|
|
49
|
+
const REFUSAL_DETAIL = {
|
|
50
|
+
CE_HOST_ENDPOINT_NOT_ALLOWED: "endpoint is not in the host allowlist",
|
|
51
|
+
CE_HOST_NOT_REGISTERED: "endpoint has no provisioned host session; run session startup first",
|
|
52
|
+
CE_HOST_MAPPING_INVALID: "persisted mapping is stale, foreign, or malformed; operator re-bind required",
|
|
53
|
+
CE_HOST_AUTHORITY_ABSENT: "no current ownership authority for this endpoint; reauthorization required",
|
|
54
|
+
CE_HOST_RECIPIENT_UNKNOWN: "recipient endpoint is not in the host allowlist",
|
|
55
|
+
CE_HOST_INVALID_REQUEST: "request shape rejected",
|
|
56
|
+
CE_HOST_BUSY: "durable store is contended; the request rolled back and may be retried as a whole",
|
|
57
|
+
CE_HOST_UNCERTAIN: "transaction outcome is unknown; reopen and inspect durable state before any retry",
|
|
58
|
+
CE_HOST_ACK_DISABLED: "acknowledgement recording is disabled by operator policy",
|
|
59
|
+
CE_HOST_PAYLOAD_DISABLED: "payload reading is disabled by operator policy",
|
|
60
|
+
CE_HOST_PAYLOAD_TOO_LARGE: "payload exceeds the fixed operator exposure limit",
|
|
61
|
+
CE_HOST_PAYLOAD_INVALID_UTF8: "payload is not valid UTF-8 text",
|
|
62
|
+
CE_HOST_UNAVAILABLE: "host operation failed closed",
|
|
63
|
+
};
|
|
64
|
+
/** Sanitized refusal. `reason` carries only an already-safe durable code
|
|
65
|
+
* (fixed strings from the storage plane) for observability; the message is a
|
|
66
|
+
* fixed string and never interpolates request data, tokens, or causes. */
|
|
67
|
+
export class CeHostRefusal extends Error {
|
|
68
|
+
outcome = "ROLLED_BACK";
|
|
69
|
+
retryable;
|
|
70
|
+
code;
|
|
71
|
+
reason;
|
|
72
|
+
constructor(code, retryable = false, reason) {
|
|
73
|
+
super(REFUSAL_DETAIL[code]);
|
|
74
|
+
this.name = "CeHostRefusal";
|
|
75
|
+
this.code = code;
|
|
76
|
+
this.retryable = retryable;
|
|
77
|
+
this.reason = reason;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** CE-0002-B3-P4D fixed maximum encoded payload bytes the host will ever
|
|
81
|
+
* decode and expose. Checked on the authorized byte length before decoding;
|
|
82
|
+
* an oversized payload is refused whole, never partially returned. */
|
|
83
|
+
const PAYLOAD_EXPOSURE_LIMIT_BYTES = 65_536;
|
|
84
|
+
/** HANDOFF-CE-E2B-01 closed ACK request: exactly the Message metadata a caller
|
|
85
|
+
* must copy from an authorized read. No authority field is accepted here. */
|
|
86
|
+
const ACK_REQUEST_FIELDS = ["messageId", "payloadDigest", "payloadLength", "messageCreatedAuditSequence"];
|
|
87
|
+
function isSafeIntegerAtLeast(value, minimum) {
|
|
88
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
|
|
89
|
+
}
|
|
90
|
+
/** Only the exact boolean `true` enables payload exposure. A missing, false,
|
|
91
|
+
* or malformed setting (wrong type, truthy string, number, null, or a
|
|
92
|
+
* non-object section) fails closed to disabled. */
|
|
93
|
+
function optInEnabled(setting) {
|
|
94
|
+
try {
|
|
95
|
+
if (!isPlainObject(setting) || Array.isArray(setting))
|
|
96
|
+
return false;
|
|
97
|
+
const enabled = Object.getOwnPropertyDescriptor(setting, "enabled");
|
|
98
|
+
return enabled?.enumerable === true && "value" in enabled && enabled.value === true;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function payloadReadEnabled(policy) {
|
|
105
|
+
try {
|
|
106
|
+
return optInEnabled(policy.payloadRead);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** HANDOFF-CE-E2B-01: the ACK opt-in uses the identical exact-`true` rule. */
|
|
113
|
+
function ackEnabled(policy) {
|
|
114
|
+
try {
|
|
115
|
+
return optInEnabled(policy.ack);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function clampLeaseTtl(policy) {
|
|
122
|
+
if (policy.leaseTtlSeconds === undefined)
|
|
123
|
+
return LEASE_DEFAULT_TTL_SECONDS;
|
|
124
|
+
if (!Number.isSafeInteger(policy.leaseTtlSeconds) ||
|
|
125
|
+
policy.leaseTtlSeconds < LEASE_MIN_TTL_SECONDS ||
|
|
126
|
+
policy.leaseTtlSeconds > LEASE_MAX_TTL_SECONDS) {
|
|
127
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
128
|
+
}
|
|
129
|
+
return policy.leaseTtlSeconds;
|
|
130
|
+
}
|
|
131
|
+
function isPlainObject(value) {
|
|
132
|
+
if (typeof value !== "object" || value === null)
|
|
133
|
+
return false;
|
|
134
|
+
const prototype = Object.getPrototypeOf(value);
|
|
135
|
+
return prototype === Object.prototype || prototype === null;
|
|
136
|
+
}
|
|
137
|
+
/** Map any storage-plane failure to a sanitized refusal. Domain codes pass
|
|
138
|
+
* through as `reason` (they are fixed, secret-free strings by contract);
|
|
139
|
+
* messages, causes, and unknown errors never escape. */
|
|
140
|
+
function toRefusal(error) {
|
|
141
|
+
if (error instanceof CeHostRefusal)
|
|
142
|
+
return error;
|
|
143
|
+
if (error instanceof StorageTransactionError) {
|
|
144
|
+
if (error.retryable)
|
|
145
|
+
return new CeHostRefusal("CE_HOST_BUSY", true, error.code);
|
|
146
|
+
if (error.outcome === "INDETERMINATE") {
|
|
147
|
+
return new CeHostRefusal("CE_HOST_UNCERTAIN", false, error.code);
|
|
148
|
+
}
|
|
149
|
+
return new CeHostRefusal("CE_HOST_UNAVAILABLE", false, error.code);
|
|
150
|
+
}
|
|
151
|
+
if (error instanceof ParticipantDomainError || error instanceof CreationDomainError) {
|
|
152
|
+
// Ownership and lease refusals surface as authority-absence so the caller
|
|
153
|
+
// reauthorizes instead of retrying; every other durable refusal keeps its
|
|
154
|
+
// fixed domain code as the sanitized reason.
|
|
155
|
+
const authorityAbsent = error.code === "OWNERSHIP_NOT_CURRENT" ||
|
|
156
|
+
error.code === "OWNERSHIP_HELD" ||
|
|
157
|
+
error.code === "OWNERSHIP_TAKEOVER_REQUIRED" ||
|
|
158
|
+
error.code === "LEASE_EXPIRED" ||
|
|
159
|
+
error.code === "LEASE_CLOCK_REGRESSION" ||
|
|
160
|
+
error.code === "FENCING_STALE";
|
|
161
|
+
if (authorityAbsent)
|
|
162
|
+
return new CeHostRefusal("CE_HOST_AUTHORITY_ABSENT", false, error.code);
|
|
163
|
+
return new CeHostRefusal("CE_HOST_UNAVAILABLE", false, error.code);
|
|
164
|
+
}
|
|
165
|
+
if (error instanceof ParticipantRequestError || error instanceof CreationRequestError) {
|
|
166
|
+
return new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
167
|
+
}
|
|
168
|
+
if (error instanceof StoreLifecycleError) {
|
|
169
|
+
return new CeHostRefusal("CE_HOST_UNAVAILABLE", false, error.code);
|
|
170
|
+
}
|
|
171
|
+
return new CeHostRefusal("CE_HOST_UNAVAILABLE");
|
|
172
|
+
}
|
|
173
|
+
const ADAPTER_MAX_LENGTH = 64;
|
|
174
|
+
const ENDPOINT_ID_MAX_LENGTH = 256;
|
|
175
|
+
function isEndpointLabel(value, max) {
|
|
176
|
+
return typeof value === "string" && value.length > 0 && value.length <= max;
|
|
177
|
+
}
|
|
178
|
+
/** Strictly validate and freeze the operator's explicit cross-adapter
|
|
179
|
+
* recipients. Anything malformed refuses construction (fail closed): a
|
|
180
|
+
* non-array, a non-plain entry, an unknown or accessor-backed field, a bad
|
|
181
|
+
* label, a duplicate, a same-adapter entry, or a label colliding with an
|
|
182
|
+
* allowlisted Endpoint label. */
|
|
183
|
+
function validatedRecipients(policy, allowlistLabels) {
|
|
184
|
+
const raw = policy.recipients;
|
|
185
|
+
if (raw === undefined)
|
|
186
|
+
return Object.freeze([]);
|
|
187
|
+
if (!Array.isArray(raw))
|
|
188
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
189
|
+
const labels = new Set();
|
|
190
|
+
const recipients = [];
|
|
191
|
+
for (const entry of raw) {
|
|
192
|
+
if (!isPlainObject(entry))
|
|
193
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
194
|
+
const keys = Reflect.ownKeys(entry);
|
|
195
|
+
if (keys.length !== 2 || !keys.includes("adapter") || !keys.includes("endpointId")) {
|
|
196
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
197
|
+
}
|
|
198
|
+
const adapterField = Object.getOwnPropertyDescriptor(entry, "adapter");
|
|
199
|
+
const endpointField = Object.getOwnPropertyDescriptor(entry, "endpointId");
|
|
200
|
+
if (adapterField === undefined || !("value" in adapterField) || endpointField === undefined || !("value" in endpointField)) {
|
|
201
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
202
|
+
}
|
|
203
|
+
const adapter = adapterField.value;
|
|
204
|
+
const endpointId = endpointField.value;
|
|
205
|
+
if (!isEndpointLabel(adapter, ADAPTER_MAX_LENGTH) || !isEndpointLabel(endpointId, ENDPOINT_ID_MAX_LENGTH)) {
|
|
206
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
207
|
+
}
|
|
208
|
+
if (adapter === policy.adapter)
|
|
209
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
210
|
+
if (labels.has(endpointId) || allowlistLabels.has(endpointId))
|
|
211
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
212
|
+
labels.add(endpointId);
|
|
213
|
+
recipients.push(Object.freeze({ adapter, endpointId }));
|
|
214
|
+
}
|
|
215
|
+
return Object.freeze(recipients);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The trusted host adapter. One instance per host process; one authority row
|
|
219
|
+
* per allowlisted Endpoint.
|
|
220
|
+
*/
|
|
221
|
+
export class CeEndpointHost {
|
|
222
|
+
#store;
|
|
223
|
+
#policy;
|
|
224
|
+
#leaseTtlSeconds;
|
|
225
|
+
/** CE-0002-B3-P4D operator payload-read decision, fixed for the Host
|
|
226
|
+
* lifetime: computed once from the policy so later mutation of the policy
|
|
227
|
+
* object can never enable or disable payload exposure mid-flight. */
|
|
228
|
+
#payloadReadEnabled;
|
|
229
|
+
/** HANDOFF-CE-E2B-01 operator ACK decision, fixed for the Host lifetime by
|
|
230
|
+
* the same rule, so later mutation of the policy object cannot enable or
|
|
231
|
+
* disable the ACK surface mid-flight. */
|
|
232
|
+
#ackEnabled;
|
|
233
|
+
/** Explicit cross-adapter recipients, validated and frozen at construction. */
|
|
234
|
+
#recipients;
|
|
235
|
+
#seams;
|
|
236
|
+
#authorities = new Map();
|
|
237
|
+
constructor(store, policy) {
|
|
238
|
+
if (!isEndpointLabel(policy.adapter, ADAPTER_MAX_LENGTH))
|
|
239
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
240
|
+
if (policy.allowlist.length === 0)
|
|
241
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
242
|
+
const seen = new Set();
|
|
243
|
+
for (const entry of policy.allowlist) {
|
|
244
|
+
if (typeof entry.endpointId !== "string" || entry.endpointId.length === 0 || entry.endpointId.length > ENDPOINT_ID_MAX_LENGTH) {
|
|
245
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
246
|
+
}
|
|
247
|
+
if (seen.has(entry.endpointId))
|
|
248
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
249
|
+
seen.add(entry.endpointId);
|
|
250
|
+
}
|
|
251
|
+
this.#recipients = validatedRecipients(policy, seen);
|
|
252
|
+
this.#store = store;
|
|
253
|
+
this.#policy = policy;
|
|
254
|
+
this.#leaseTtlSeconds = clampLeaseTtl(policy);
|
|
255
|
+
this.#payloadReadEnabled = payloadReadEnabled(policy);
|
|
256
|
+
this.#ackEnabled = ackEnabled(policy);
|
|
257
|
+
this.#seams = policy.now === undefined ? {} : { now: policy.now };
|
|
258
|
+
}
|
|
259
|
+
/** The durable Exchange this host operates. Creation is idempotent by the
|
|
260
|
+
* deterministic policy identity, so every call converges on one Exchange. */
|
|
261
|
+
#exchangeId() {
|
|
262
|
+
return createExchange(this.#store, { namespace: this.#policy.exchangeNamespace, idempotencyKey: this.#policy.exchangeIdempotencyKey }, this.#seams).exchange.exchangeId;
|
|
263
|
+
}
|
|
264
|
+
#allowlisted(endpointId) {
|
|
265
|
+
const entry = this.#policy.allowlist.find((candidate) => candidate.endpointId === endpointId);
|
|
266
|
+
if (entry === undefined)
|
|
267
|
+
throw new CeHostRefusal("CE_HOST_ENDPOINT_NOT_ALLOWED");
|
|
268
|
+
return entry;
|
|
269
|
+
}
|
|
270
|
+
/** Resolve a recipient label: an allowlisted same-adapter Endpoint first,
|
|
271
|
+
* otherwise an explicitly configured cross-adapter recipient. Any other
|
|
272
|
+
* label is refused exactly like before (`CE_HOST_ENDPOINT_NOT_ALLOWED`). */
|
|
273
|
+
#recipient(label) {
|
|
274
|
+
if (this.#policy.allowlist.some((candidate) => candidate.endpointId === label)) {
|
|
275
|
+
return { adapter: this.#policy.adapter, endpointId: label };
|
|
276
|
+
}
|
|
277
|
+
const external = this.#recipients.find((candidate) => candidate.endpointId === label);
|
|
278
|
+
if (external === undefined)
|
|
279
|
+
throw new CeHostRefusal("CE_HOST_ENDPOINT_NOT_ALLOWED");
|
|
280
|
+
return external;
|
|
281
|
+
}
|
|
282
|
+
#authority(endpointId) {
|
|
283
|
+
const authority = this.#authorities.get(endpointId);
|
|
284
|
+
if (authority === undefined)
|
|
285
|
+
throw new CeHostRefusal("CE_HOST_NOT_REGISTERED");
|
|
286
|
+
return authority;
|
|
287
|
+
}
|
|
288
|
+
/** Current fencing token plus lease validation for a registered Endpoint.
|
|
289
|
+
* Refuses when ownership is absent, expired, or held by another Session;
|
|
290
|
+
* never adopts or repairs authority. */
|
|
291
|
+
#currentFencing(authority) {
|
|
292
|
+
const ownership = readCurrentOwnership(this.#store, authority.participantId);
|
|
293
|
+
if (ownership === undefined || ownership.sessionId !== authority.sessionId) {
|
|
294
|
+
throw new CeHostRefusal("CE_HOST_AUTHORITY_ABSENT", false, "OWNERSHIP_NOT_CURRENT");
|
|
295
|
+
}
|
|
296
|
+
const lease = readLeaseStatus(this.#store, { participantId: authority.participantId }, this.#seams);
|
|
297
|
+
if (lease !== "ACTIVE") {
|
|
298
|
+
throw new CeHostRefusal("CE_HOST_AUTHORITY_ABSENT", false, lease);
|
|
299
|
+
}
|
|
300
|
+
return ownership.fencingToken;
|
|
301
|
+
}
|
|
302
|
+
/** Lazily issue (once per authority) the Session-bound `resume` capability
|
|
303
|
+
* used only by the explicit lifecycle operations. Memory-only. */
|
|
304
|
+
#resumeCapability(authority) {
|
|
305
|
+
if (authority.resumeCapability === undefined) {
|
|
306
|
+
authority.resumeCapability = issueCapability(this.#store, { participantId: authority.participantId, scope: "resume", sessionId: authority.sessionId }, this.#seams).capability;
|
|
307
|
+
}
|
|
308
|
+
return authority.resumeCapability;
|
|
309
|
+
}
|
|
310
|
+
/** `requireUnowned` guard: pure reads, evaluated before ANY durable write.
|
|
311
|
+
* An existing ownership row (live, expired, or clock-regressed) refuses the
|
|
312
|
+
* start; there is no adoption and no takeover. */
|
|
313
|
+
#refuseIfOwned(endpointId) {
|
|
314
|
+
try {
|
|
315
|
+
const existing = readParticipantByEndpoint(this.#store, this.#policy.adapter, endpointId);
|
|
316
|
+
if (existing === undefined)
|
|
317
|
+
return;
|
|
318
|
+
if (readCurrentOwnership(this.#store, existing.participantId) === undefined)
|
|
319
|
+
return;
|
|
320
|
+
const lease = readLeaseStatus(this.#store, { participantId: existing.participantId }, this.#seams);
|
|
321
|
+
const reason = lease === "ACTIVE"
|
|
322
|
+
? "OWNERSHIP_HELD"
|
|
323
|
+
: lease === "CLOCK_REGRESSION" ? "LEASE_CLOCK_REGRESSION" : "OWNERSHIP_TAKEOVER_REQUIRED";
|
|
324
|
+
throw new CeHostRefusal("CE_HOST_AUTHORITY_ABSENT", false, reason);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
throw toRefusal(error);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/** Best-effort self-detach of a Session this host just bound but could not
|
|
331
|
+
* make Owner (a lost race after the `requireUnowned` pre-check). Uses the
|
|
332
|
+
* Session's own `resume` capability; never touches ownership. A failure
|
|
333
|
+
* leaves the refused start's original refusal authoritative. */
|
|
334
|
+
#detachOrphan(participantId, sessionId) {
|
|
335
|
+
try {
|
|
336
|
+
const capability = issueCapability(this.#store, { participantId, scope: "resume", sessionId }, this.#seams).capability;
|
|
337
|
+
detachSession(this.#store, { participantId, sessionId, capability }, this.#seams);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// The acquisition refusal is reported; the orphan carries no ownership.
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Provision a brand-new CE Session for an allowlisted Endpoint: reuse or
|
|
344
|
+
* create the Participant, ensure membership, bind a fresh CE Session,
|
|
345
|
+
* acquire ownership, and issue Session-bound send/read capabilities.
|
|
346
|
+
* Idempotent registrations/joins replay; ownership refusal fails closed.
|
|
347
|
+
* With `requireUnowned`, an existing ownership row refuses before any write. */
|
|
348
|
+
beginSession(endpointId, options = {}) {
|
|
349
|
+
this.#allowlisted(endpointId);
|
|
350
|
+
const adapter = this.#policy.adapter;
|
|
351
|
+
const requireUnowned = options.requireUnowned === true;
|
|
352
|
+
if (requireUnowned)
|
|
353
|
+
this.#refuseIfOwned(endpointId);
|
|
354
|
+
let exchangeId;
|
|
355
|
+
let participantId;
|
|
356
|
+
try {
|
|
357
|
+
exchangeId = this.#exchangeId();
|
|
358
|
+
const existing = readParticipantByEndpoint(this.#store, adapter, endpointId);
|
|
359
|
+
if (existing !== undefined) {
|
|
360
|
+
participantId = existing.participantId;
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
participantId = createParticipant(this.#store, {
|
|
364
|
+
endpoint: { adapter, endpointId },
|
|
365
|
+
idempotencyKey: `ce-host:register:${adapter}:${endpointId}`,
|
|
366
|
+
}, this.#seams).participant.participantId;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
throw toRefusal(error);
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
const joinCapability = issueCapability(this.#store, { participantId, scope: "join" }, this.#seams).capability;
|
|
374
|
+
joinExchange(this.#store, { exchangeId, participantId, capability: joinCapability, idempotencyKey: `ce-host:join:${exchangeId}:${participantId}` }, this.#seams);
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
throw toRefusal(error);
|
|
378
|
+
}
|
|
379
|
+
const sessionId = generateSessionId();
|
|
380
|
+
try {
|
|
381
|
+
const attachCapability = issueCapability(this.#store, { participantId, scope: "attach" }, this.#seams).capability;
|
|
382
|
+
bindSession(this.#store, { participantId, sessionId, capability: attachCapability }, this.#seams);
|
|
383
|
+
try {
|
|
384
|
+
acquireOwnership(this.#store, { participantId, sessionId, capability: attachCapability, leaseTtlSeconds: this.#leaseTtlSeconds }, this.#seams);
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
if (requireUnowned)
|
|
388
|
+
this.#detachOrphan(participantId, sessionId);
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
const sendCapability = issueCapability(this.#store, { participantId, scope: "send", sessionId }, this.#seams).capability;
|
|
392
|
+
const readCapability = issueCapability(this.#store, { participantId, scope: "read", sessionId }, this.#seams).capability;
|
|
393
|
+
// CE-0002-B3-P4D: issued only after ownership acquisition succeeded and
|
|
394
|
+
// only when the operator opt-in is enabled; exact Session binding and
|
|
395
|
+
// the current trust generation come from the storage issuance itself.
|
|
396
|
+
const payloadReadCapability = this.#payloadReadEnabled
|
|
397
|
+
? issueCapability(this.#store, { participantId, scope: "payload_read", sessionId }, this.#seams).capability
|
|
398
|
+
: undefined;
|
|
399
|
+
// HANDOFF-CE-E2B-01: the independent Session-bound `ack` scope, issued
|
|
400
|
+
// only under the operator opt-in and never persisted or returned.
|
|
401
|
+
const ackCapability = this.#ackEnabled
|
|
402
|
+
? issueCapability(this.#store, { participantId, scope: "ack", sessionId }, this.#seams).capability
|
|
403
|
+
: undefined;
|
|
404
|
+
this.#authorities.set(endpointId, {
|
|
405
|
+
endpointId,
|
|
406
|
+
participantId,
|
|
407
|
+
sessionId,
|
|
408
|
+
exchangeId,
|
|
409
|
+
sendCapability,
|
|
410
|
+
readCapability,
|
|
411
|
+
payloadReadCapability,
|
|
412
|
+
ackCapability,
|
|
413
|
+
resumeCapability: undefined,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
throw toRefusal(error);
|
|
418
|
+
}
|
|
419
|
+
return Object.freeze({ endpointId, participantId, sessionId, exchangeId });
|
|
420
|
+
}
|
|
421
|
+
/** Recover authority after a restart from persisted mapping identifiers.
|
|
422
|
+
* Validates the durable bindings before re-issuing capabilities; refuses
|
|
423
|
+
* stale, foreign, detached, or unowned mappings. Performs no binding, no
|
|
424
|
+
* ownership change, and no takeover. */
|
|
425
|
+
resume(endpointId, mapping) {
|
|
426
|
+
this.#allowlisted(endpointId);
|
|
427
|
+
if (!isPlainObject(mapping))
|
|
428
|
+
throw new CeHostRefusal("CE_HOST_MAPPING_INVALID");
|
|
429
|
+
const { participantId, sessionId, exchangeId } = mapping;
|
|
430
|
+
if (typeof participantId !== "string" || !isParticipantId(participantId)) {
|
|
431
|
+
throw new CeHostRefusal("CE_HOST_MAPPING_INVALID");
|
|
432
|
+
}
|
|
433
|
+
if (typeof sessionId !== "string" || !isSessionId(sessionId)) {
|
|
434
|
+
throw new CeHostRefusal("CE_HOST_MAPPING_INVALID");
|
|
435
|
+
}
|
|
436
|
+
if (typeof exchangeId !== "string" || !EXCHANGE_ID_PATTERN.test(exchangeId)) {
|
|
437
|
+
throw new CeHostRefusal("CE_HOST_MAPPING_INVALID");
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
if (exchangeId !== this.#exchangeId())
|
|
441
|
+
throw new CeHostRefusal("CE_HOST_MAPPING_INVALID");
|
|
442
|
+
const participant = readParticipantByEndpoint(this.#store, this.#policy.adapter, endpointId);
|
|
443
|
+
if (participant === undefined || participant.participantId !== participantId) {
|
|
444
|
+
throw new CeHostRefusal("CE_HOST_MAPPING_INVALID");
|
|
445
|
+
}
|
|
446
|
+
const lifecycle = readSessionLifecycle(this.#store, { participantId, sessionId });
|
|
447
|
+
if (lifecycle.state !== "ATTACHED")
|
|
448
|
+
throw new CeHostRefusal("CE_HOST_MAPPING_INVALID");
|
|
449
|
+
const ownership = readCurrentOwnership(this.#store, participantId);
|
|
450
|
+
if (ownership === undefined || ownership.sessionId !== sessionId) {
|
|
451
|
+
throw new CeHostRefusal("CE_HOST_AUTHORITY_ABSENT", false, "OWNERSHIP_NOT_CURRENT");
|
|
452
|
+
}
|
|
453
|
+
const lease = readLeaseStatus(this.#store, { participantId }, this.#seams);
|
|
454
|
+
if (lease !== "ACTIVE") {
|
|
455
|
+
throw new CeHostRefusal("CE_HOST_AUTHORITY_ABSENT", false, lease);
|
|
456
|
+
}
|
|
457
|
+
const sendCapability = issueCapability(this.#store, { participantId, scope: "send", sessionId }, this.#seams).capability;
|
|
458
|
+
const readCapability = issueCapability(this.#store, { participantId, scope: "read", sessionId }, this.#seams).capability;
|
|
459
|
+
// CE-0002-B3-P4D: a validated restart within the active lease re-issues
|
|
460
|
+
// fresh memory-only payload authority under the same immutable policy.
|
|
461
|
+
const payloadReadCapability = this.#payloadReadEnabled
|
|
462
|
+
? issueCapability(this.#store, { participantId, scope: "payload_read", sessionId }, this.#seams).capability
|
|
463
|
+
: undefined;
|
|
464
|
+
// HANDOFF-CE-E2B-01: a validated restart within the active lease also
|
|
465
|
+
// re-issues fresh memory-only ack authority under the same policy.
|
|
466
|
+
const ackCapability = this.#ackEnabled
|
|
467
|
+
? issueCapability(this.#store, { participantId, scope: "ack", sessionId }, this.#seams).capability
|
|
468
|
+
: undefined;
|
|
469
|
+
this.#authorities.set(endpointId, {
|
|
470
|
+
endpointId,
|
|
471
|
+
participantId,
|
|
472
|
+
sessionId,
|
|
473
|
+
exchangeId,
|
|
474
|
+
sendCapability,
|
|
475
|
+
readCapability,
|
|
476
|
+
payloadReadCapability,
|
|
477
|
+
ackCapability,
|
|
478
|
+
resumeCapability: undefined,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
throw toRefusal(error);
|
|
483
|
+
}
|
|
484
|
+
return Object.freeze({ endpointId, participantId, sessionId, exchangeId });
|
|
485
|
+
}
|
|
486
|
+
/** Session lifecycle cleanup: drop the in-memory authority row (including
|
|
487
|
+
* the plaintext capabilities). Durable ownership is left to lease expiry;
|
|
488
|
+
* this adapter never releases or transfers ownership implicitly. */
|
|
489
|
+
drop(endpointId) {
|
|
490
|
+
this.#authorities.delete(endpointId);
|
|
491
|
+
}
|
|
492
|
+
/** Explicit lease renewal by the current Owner (opt-in lifecycle). Storage
|
|
493
|
+
* evaluates the persisted term at the serialization instant: an expired or
|
|
494
|
+
* clock-regressed lease is refused and never revived; the fencing token is
|
|
495
|
+
* unchanged. No retry, takeover, or re-acquisition happens here. */
|
|
496
|
+
renew(endpointId) {
|
|
497
|
+
const authority = this.#authority(endpointId);
|
|
498
|
+
try {
|
|
499
|
+
const fencingToken = this.#currentFencing(authority);
|
|
500
|
+
const capability = this.#resumeCapability(authority);
|
|
501
|
+
const lease = renewLease(this.#store, {
|
|
502
|
+
participantId: authority.participantId,
|
|
503
|
+
sessionId: authority.sessionId,
|
|
504
|
+
fencingToken,
|
|
505
|
+
capability,
|
|
506
|
+
leaseTtlSeconds: this.#leaseTtlSeconds,
|
|
507
|
+
}, this.#seams);
|
|
508
|
+
return Object.freeze({ fencingToken: lease.fencingToken, expiresAt: lease.expiresAt });
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
throw toRefusal(error);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/** Explicit graceful end (opt-in lifecycle): release ownership as the
|
|
515
|
+
* current Owner, then detach the Session with its own `resume` capability.
|
|
516
|
+
* The in-memory authority is dropped first, whatever the outcome, so an
|
|
517
|
+
* unknown outcome is never retried through this host instance. A release
|
|
518
|
+
* refusal (expired lease, not current, uncertain) is surfaced; ownership is
|
|
519
|
+
* then left to the durable state and later starts fail closed. */
|
|
520
|
+
end(endpointId) {
|
|
521
|
+
const authority = this.#authority(endpointId);
|
|
522
|
+
this.#authorities.delete(endpointId);
|
|
523
|
+
let capability;
|
|
524
|
+
try {
|
|
525
|
+
const fencingToken = this.#currentFencing(authority);
|
|
526
|
+
capability = this.#resumeCapability(authority);
|
|
527
|
+
releaseOwnership(this.#store, { participantId: authority.participantId, sessionId: authority.sessionId, fencingToken, capability }, this.#seams);
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
throw toRefusal(error);
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
detachSession(this.#store, { participantId: authority.participantId, sessionId: authority.sessionId, capability }, this.#seams);
|
|
534
|
+
return Object.freeze({ released: true, detached: true });
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
// Ownership is already released; an unconfirmed detach leaves an
|
|
538
|
+
// unowned ATTACHED Session that authorizes nothing without capabilities.
|
|
539
|
+
return Object.freeze({ released: true, detached: false });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/** Session-authorized durable send. All authority comes from host memory;
|
|
543
|
+
* the model supplies only the recipient label, the text, and an idempotency
|
|
544
|
+
* key. */
|
|
545
|
+
send(endpointId, input) {
|
|
546
|
+
const authority = this.#authority(endpointId);
|
|
547
|
+
if (!isPlainObject(input))
|
|
548
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
549
|
+
const { to, text, idempotencyKey } = input;
|
|
550
|
+
if (typeof to !== "string" || to.length === 0 || to.length > 256) {
|
|
551
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
552
|
+
}
|
|
553
|
+
const recipient = this.#recipient(to);
|
|
554
|
+
if (typeof text !== "string" || text.length === 0)
|
|
555
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
556
|
+
if (typeof idempotencyKey !== "string" || idempotencyKey.length === 0 || idempotencyKey.length > 256) {
|
|
557
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
558
|
+
}
|
|
559
|
+
if (to === endpointId)
|
|
560
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
561
|
+
const adapter = this.#policy.adapter;
|
|
562
|
+
let creation;
|
|
563
|
+
try {
|
|
564
|
+
const fencingToken = this.#currentFencing(authority);
|
|
565
|
+
creation = sendMessage(this.#store, {
|
|
566
|
+
participantId: authority.participantId,
|
|
567
|
+
sessionId: authority.sessionId,
|
|
568
|
+
fencingToken,
|
|
569
|
+
capability: authority.sendCapability,
|
|
570
|
+
message: {
|
|
571
|
+
exchangeId: authority.exchangeId,
|
|
572
|
+
from: { adapter, endpointId },
|
|
573
|
+
to: { adapter: recipient.adapter, endpointId: recipient.endpointId },
|
|
574
|
+
payload: new TextEncoder().encode(text),
|
|
575
|
+
idempotencyKey,
|
|
576
|
+
},
|
|
577
|
+
}, this.#seams);
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
throw toRefusal(error);
|
|
581
|
+
}
|
|
582
|
+
return Object.freeze({
|
|
583
|
+
disposition: creation.disposition,
|
|
584
|
+
messageId: creation.message.messageId,
|
|
585
|
+
auditSequence: creation.auditSequence,
|
|
586
|
+
recipient: to,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
/** Repeatable recipient-only protected metadata read. Payload bytes never
|
|
590
|
+
* leave the storage plane. */
|
|
591
|
+
readMetadata(endpointId, input) {
|
|
592
|
+
const authority = this.#authority(endpointId);
|
|
593
|
+
if (!isPlainObject(input))
|
|
594
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
595
|
+
const { messageId } = input;
|
|
596
|
+
if (typeof messageId !== "string" || !MESSAGE_ID_PATTERN.test(messageId)) {
|
|
597
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
598
|
+
}
|
|
599
|
+
let metadata;
|
|
600
|
+
try {
|
|
601
|
+
const fencingToken = this.#currentFencing(authority);
|
|
602
|
+
metadata = readMessageMetadata(this.#store, {
|
|
603
|
+
participantId: authority.participantId,
|
|
604
|
+
sessionId: authority.sessionId,
|
|
605
|
+
fencingToken,
|
|
606
|
+
capability: authority.readCapability,
|
|
607
|
+
exchangeId: authority.exchangeId,
|
|
608
|
+
messageId,
|
|
609
|
+
}, this.#seams);
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
throw toRefusal(error);
|
|
613
|
+
}
|
|
614
|
+
const message = metadata.message;
|
|
615
|
+
return Object.freeze({
|
|
616
|
+
messageId: message.messageId,
|
|
617
|
+
exchangeId: message.exchangeId,
|
|
618
|
+
from: message.from.endpointId,
|
|
619
|
+
to: message.to.endpointId,
|
|
620
|
+
state: message.state,
|
|
621
|
+
payloadDigest: message.payloadDigest,
|
|
622
|
+
payloadLength: message.payloadLength,
|
|
623
|
+
createdAt: message.createdAt,
|
|
624
|
+
auditSequence: metadata.auditSequence,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/** CE-0002-B3-P4D authorized payload read. The fixed sanitized policy gate
|
|
628
|
+
* comes first, so a disabled deployment answers identically regardless of
|
|
629
|
+
* registration state; then the plaintext capability never leaves this
|
|
630
|
+
* boundary and Storage alone makes the final authorization decision. The
|
|
631
|
+
* exposure limit is checked on the authorized byte length before any
|
|
632
|
+
* decoding, and decoding is strict UTF-8 — no truncation, replacement
|
|
633
|
+
* characters, base64 fallback, or partial payload ever escapes. */
|
|
634
|
+
readPayload(endpointId, input) {
|
|
635
|
+
if (!this.#payloadReadEnabled)
|
|
636
|
+
throw new CeHostRefusal("CE_HOST_PAYLOAD_DISABLED");
|
|
637
|
+
const authority = this.#authority(endpointId);
|
|
638
|
+
let messageId;
|
|
639
|
+
try {
|
|
640
|
+
({ messageId } = captureFields(input, ["messageId"], ["messageId"], "request"));
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
644
|
+
}
|
|
645
|
+
if (typeof messageId !== "string" || !MESSAGE_ID_PATTERN.test(messageId)) {
|
|
646
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
647
|
+
}
|
|
648
|
+
if (authority.payloadReadCapability === undefined) {
|
|
649
|
+
throw new CeHostRefusal("CE_HOST_PAYLOAD_DISABLED");
|
|
650
|
+
}
|
|
651
|
+
let authorized;
|
|
652
|
+
try {
|
|
653
|
+
const fencingToken = this.#currentFencing(authority);
|
|
654
|
+
authorized = readMessagePayload(this.#store, {
|
|
655
|
+
participantId: authority.participantId,
|
|
656
|
+
sessionId: authority.sessionId,
|
|
657
|
+
fencingToken,
|
|
658
|
+
capability: authority.payloadReadCapability,
|
|
659
|
+
exchangeId: authority.exchangeId,
|
|
660
|
+
messageId,
|
|
661
|
+
}, this.#seams);
|
|
662
|
+
}
|
|
663
|
+
catch (error) {
|
|
664
|
+
throw toRefusal(error);
|
|
665
|
+
}
|
|
666
|
+
// Fixed exposure limit, checked before decoding; refuse whole.
|
|
667
|
+
if (authorized.payload.byteLength > PAYLOAD_EXPOSURE_LIMIT_BYTES) {
|
|
668
|
+
throw new CeHostRefusal("CE_HOST_PAYLOAD_TOO_LARGE");
|
|
669
|
+
}
|
|
670
|
+
let payloadText;
|
|
671
|
+
try {
|
|
672
|
+
// Preserve a leading U+FEFF as data rather than silently stripping it.
|
|
673
|
+
payloadText = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(authorized.payload);
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
throw new CeHostRefusal("CE_HOST_PAYLOAD_INVALID_UTF8");
|
|
677
|
+
}
|
|
678
|
+
const message = authorized.message;
|
|
679
|
+
return Object.freeze({
|
|
680
|
+
messageId: message.messageId,
|
|
681
|
+
exchangeId: message.exchangeId,
|
|
682
|
+
from: message.from.endpointId,
|
|
683
|
+
to: message.to.endpointId,
|
|
684
|
+
payloadDigest: message.payloadDigest,
|
|
685
|
+
payloadLength: message.payloadLength,
|
|
686
|
+
createdAt: message.createdAt,
|
|
687
|
+
auditSequence: authorized.auditSequence,
|
|
688
|
+
payloadText,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
/** HANDOFF-CE-E2B-01 Session-authorized delivery ACK over the committed
|
|
692
|
+
* E2-A storage path. The fixed sanitized policy gate comes first, so a
|
|
693
|
+
* disabled deployment answers identically regardless of registration state;
|
|
694
|
+
* then the closed four-field request is validated, and Storage alone decides
|
|
695
|
+
* authority, eligibility, and natural-key idempotency inside one owned
|
|
696
|
+
* transaction. The caller supplies NO authority field: Participant, Session,
|
|
697
|
+
* fencing token, capability, Exchange, and trusted clock all come from host
|
|
698
|
+
* memory. Recording an ACK changes no Message delivery state, no cursor, and
|
|
699
|
+
* no task state, and it is not proof of reading, processing, or completion. */
|
|
700
|
+
acknowledge(endpointId, input) {
|
|
701
|
+
if (!this.#ackEnabled)
|
|
702
|
+
throw new CeHostRefusal("CE_HOST_ACK_DISABLED");
|
|
703
|
+
const authority = this.#authority(endpointId);
|
|
704
|
+
let request;
|
|
705
|
+
try {
|
|
706
|
+
request = captureFields(input, ACK_REQUEST_FIELDS, ACK_REQUEST_FIELDS, "request");
|
|
707
|
+
}
|
|
708
|
+
catch {
|
|
709
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
710
|
+
}
|
|
711
|
+
const { messageId, payloadDigest, payloadLength, messageCreatedAuditSequence } = request;
|
|
712
|
+
if (typeof messageId !== "string" ||
|
|
713
|
+
!MESSAGE_ID_PATTERN.test(messageId) ||
|
|
714
|
+
typeof payloadDigest !== "string" ||
|
|
715
|
+
!SHA256_HEX_PATTERN.test(payloadDigest) ||
|
|
716
|
+
!isSafeIntegerAtLeast(payloadLength, 0) ||
|
|
717
|
+
!isSafeIntegerAtLeast(messageCreatedAuditSequence, 1)) {
|
|
718
|
+
throw new CeHostRefusal("CE_HOST_INVALID_REQUEST");
|
|
719
|
+
}
|
|
720
|
+
if (authority.ackCapability === undefined)
|
|
721
|
+
throw new CeHostRefusal("CE_HOST_ACK_DISABLED");
|
|
722
|
+
let recorded;
|
|
723
|
+
try {
|
|
724
|
+
const fencingToken = this.#currentFencing(authority);
|
|
725
|
+
recorded = recordDeliveryAck(this.#store, {
|
|
726
|
+
participantId: authority.participantId,
|
|
727
|
+
sessionId: authority.sessionId,
|
|
728
|
+
fencingToken,
|
|
729
|
+
capability: authority.ackCapability,
|
|
730
|
+
exchangeId: authority.exchangeId,
|
|
731
|
+
messageId,
|
|
732
|
+
payloadDigest,
|
|
733
|
+
payloadLength,
|
|
734
|
+
messageCreatedAuditSequence,
|
|
735
|
+
}, this.#seams);
|
|
736
|
+
}
|
|
737
|
+
catch (error) {
|
|
738
|
+
const refusal = toRefusal(error);
|
|
739
|
+
if (refusal.code === "CE_HOST_BUSY") {
|
|
740
|
+
// Proven ACK rollback is a transaction fact, not retry authorization.
|
|
741
|
+
const ackRefusal = new CeHostRefusal("CE_HOST_BUSY", false, refusal.reason);
|
|
742
|
+
ackRefusal.message = "durable store is contended; the ACK request rolled back; rollback does not authorize retry; retry requires explicit operator or upper-layer approval";
|
|
743
|
+
throw ackRefusal;
|
|
744
|
+
}
|
|
745
|
+
throw refusal;
|
|
746
|
+
}
|
|
747
|
+
const receipt = recorded.receipt;
|
|
748
|
+
// Defensive: an ACK receipt is `delivery_ack` by the storage contract.
|
|
749
|
+
// Never publish evidence that disagrees with the frozen projection.
|
|
750
|
+
if (receipt.kind !== "delivery_ack")
|
|
751
|
+
throw new CeHostRefusal("CE_HOST_UNAVAILABLE");
|
|
752
|
+
return Object.freeze({
|
|
753
|
+
disposition: recorded.disposition,
|
|
754
|
+
receipt: Object.freeze({
|
|
755
|
+
receiptId: receipt.receiptId,
|
|
756
|
+
exchangeId: receipt.exchangeId,
|
|
757
|
+
messageId: receipt.messageId,
|
|
758
|
+
endpoint: Object.freeze({ adapter: receipt.endpoint.adapter, endpointId: receipt.endpoint.endpointId }),
|
|
759
|
+
kind: receipt.kind,
|
|
760
|
+
createdAt: receipt.createdAt,
|
|
761
|
+
}),
|
|
762
|
+
receiptAuditSequence: recorded.receiptAuditSequence,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
/** Explicit, bounded metadata discovery. Pagination grants no authority and
|
|
766
|
+
* never advances ACK or delivery state. All CE authorization remains in Storage. */
|
|
767
|
+
listInbox(endpointId, input) {
|
|
768
|
+
const authority = this.#authority(endpointId);
|
|
769
|
+
try {
|
|
770
|
+
const pagination = captureFields(input, ["afterAuditSequence", "pageSize", "rawScanLimit"], [], "request");
|
|
771
|
+
const fencingToken = this.#currentFencing(authority);
|
|
772
|
+
const result = discoverInboxMessages(this.#store, {
|
|
773
|
+
...pagination,
|
|
774
|
+
participantId: authority.participantId,
|
|
775
|
+
sessionId: authority.sessionId,
|
|
776
|
+
exchangeId: authority.exchangeId,
|
|
777
|
+
capability: authority.readCapability,
|
|
778
|
+
fencingToken,
|
|
779
|
+
}, this.#seams);
|
|
780
|
+
return Object.freeze({
|
|
781
|
+
messages: Object.freeze(result.messages.map(({ message, auditSequence }) => Object.freeze({
|
|
782
|
+
messageId: message.messageId,
|
|
783
|
+
auditSequence,
|
|
784
|
+
}))),
|
|
785
|
+
continuationAuditSequence: result.continuationAuditSequence,
|
|
786
|
+
scanBudgetExhausted: result.scanBudgetExhausted,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
catch (error) {
|
|
790
|
+
throw toRefusal(error);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
/** Advisory status for the operator command. Authorizes nothing. */
|
|
794
|
+
status(endpointId) {
|
|
795
|
+
const authority = this.#authorities.get(endpointId);
|
|
796
|
+
if (authority === undefined) {
|
|
797
|
+
return Object.freeze({
|
|
798
|
+
endpointId,
|
|
799
|
+
registered: false,
|
|
800
|
+
participantId: undefined,
|
|
801
|
+
sessionId: undefined,
|
|
802
|
+
exchangeId: undefined,
|
|
803
|
+
lease: "ABSENT",
|
|
804
|
+
leaseExpiresAt: undefined,
|
|
805
|
+
fencingToken: undefined,
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
const ownership = readCurrentOwnership(this.#store, authority.participantId);
|
|
810
|
+
const lease = ownership === undefined || ownership.sessionId !== authority.sessionId
|
|
811
|
+
? "ABSENT"
|
|
812
|
+
: readLeaseStatus(this.#store, { participantId: authority.participantId }, this.#seams);
|
|
813
|
+
return Object.freeze({
|
|
814
|
+
endpointId,
|
|
815
|
+
registered: true,
|
|
816
|
+
participantId: authority.participantId,
|
|
817
|
+
sessionId: authority.sessionId,
|
|
818
|
+
exchangeId: authority.exchangeId,
|
|
819
|
+
lease,
|
|
820
|
+
leaseExpiresAt: ownership !== undefined && ownership.sessionId === authority.sessionId ? ownership.expiresAt : undefined,
|
|
821
|
+
fencingToken: ownership?.fencingToken,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
// Advisory only: on any read failure report absence rather than throw.
|
|
826
|
+
return Object.freeze({
|
|
827
|
+
endpointId,
|
|
828
|
+
registered: true,
|
|
829
|
+
participantId: authority.participantId,
|
|
830
|
+
sessionId: authority.sessionId,
|
|
831
|
+
exchangeId: authority.exchangeId,
|
|
832
|
+
lease: "ABSENT",
|
|
833
|
+
leaseExpiresAt: undefined,
|
|
834
|
+
fencingToken: undefined,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
//# sourceMappingURL=endpoint-host.js.map
|