@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,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HANDOFF-853274 — trusted MCP endpoint binding configuration.
|
|
3
|
+
*
|
|
4
|
+
* The MCP server's Endpoint identity comes ONLY from this operator-controlled
|
|
5
|
+
* launch configuration (passed by absolute path on the command line). MCP tool
|
|
6
|
+
* arguments never select a sender, endpoint, participant, session, or
|
|
7
|
+
* capability. This module is a pure validator: it reads no file, no
|
|
8
|
+
* environment, and never echoes configuration values in its errors (only a
|
|
9
|
+
* structural field path).
|
|
10
|
+
*/
|
|
11
|
+
import { isAbsolute, normalize } from "node:path";
|
|
12
|
+
import { LEASE_MAX_TTL_SECONDS, LEASE_MIN_TTL_SECONDS } from "../storage/ownership.js";
|
|
13
|
+
export const MCP_BINDING_CONFIG_VERSION = 1;
|
|
14
|
+
export const MCP_DEFAULT_LEASE_TTL_SECONDS = 120;
|
|
15
|
+
export const MCP_DEFAULT_RENEW_INTERVAL_SECONDS = 30;
|
|
16
|
+
/** Upper bound on the configuration file the composition root will read. */
|
|
17
|
+
export const MCP_BINDING_CONFIG_MAX_BYTES = 65_536;
|
|
18
|
+
/** Sanitized configuration refusal: a fixed code plus a structural field path;
|
|
19
|
+
* never a configuration value. */
|
|
20
|
+
export class McpBindingConfigError extends Error {
|
|
21
|
+
code = "CE_MCP_CONFIG_INVALID";
|
|
22
|
+
field;
|
|
23
|
+
constructor(field) {
|
|
24
|
+
super(`CE_MCP_CONFIG_INVALID at ${field}`);
|
|
25
|
+
this.name = "McpBindingConfigError";
|
|
26
|
+
this.field = field;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const LABEL_MAX = { adapter: 64, endpointId: 256, identity: 256 };
|
|
30
|
+
const STORE_FILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
31
|
+
/** Capability plaintext never belongs in operator configuration. */
|
|
32
|
+
const CREDENTIAL_SHAPED = /cap_[0-9a-f]{16,}/i;
|
|
33
|
+
function plain(value, field, keys, required) {
|
|
34
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
35
|
+
throw new McpBindingConfigError(field);
|
|
36
|
+
const prototype = Object.getPrototypeOf(value);
|
|
37
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
38
|
+
throw new McpBindingConfigError(field);
|
|
39
|
+
const own = Reflect.ownKeys(value);
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const key of own) {
|
|
42
|
+
if (typeof key !== "string" || !keys.includes(key))
|
|
43
|
+
throw new McpBindingConfigError(field);
|
|
44
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
45
|
+
if (descriptor === undefined || !("value" in descriptor) || descriptor.enumerable !== true) {
|
|
46
|
+
throw new McpBindingConfigError(`${field}.${key}`);
|
|
47
|
+
}
|
|
48
|
+
out[key] = descriptor.value;
|
|
49
|
+
}
|
|
50
|
+
for (const key of required) {
|
|
51
|
+
if (!Object.hasOwn(out, key))
|
|
52
|
+
throw new McpBindingConfigError(`${field}.${key}`);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
function label(value, field, max) {
|
|
57
|
+
if (typeof value !== "string" || value.length === 0 || value.length > max || value.includes("\u0000")) {
|
|
58
|
+
throw new McpBindingConfigError(field);
|
|
59
|
+
}
|
|
60
|
+
if (CREDENTIAL_SHAPED.test(value))
|
|
61
|
+
throw new McpBindingConfigError(field);
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
function endpointRef(value, field) {
|
|
65
|
+
const fields = plain(value, field, ["adapter", "endpointId"], ["adapter", "endpointId"]);
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
adapter: label(fields.adapter, `${field}.adapter`, LABEL_MAX.adapter),
|
|
68
|
+
endpointId: label(fields.endpointId, `${field}.endpointId`, LABEL_MAX.endpointId),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function storeLocation(value) {
|
|
72
|
+
const fields = plain(value, "store", ["directory", "file"], ["directory"]);
|
|
73
|
+
const directory = fields.directory;
|
|
74
|
+
if (typeof directory !== "string" ||
|
|
75
|
+
directory.length === 0 ||
|
|
76
|
+
directory.length > 4096 ||
|
|
77
|
+
directory.includes("\u0000") ||
|
|
78
|
+
!isAbsolute(directory) ||
|
|
79
|
+
normalize(directory) !== directory ||
|
|
80
|
+
directory === "/" ||
|
|
81
|
+
directory.endsWith("/")) {
|
|
82
|
+
throw new McpBindingConfigError("store.directory");
|
|
83
|
+
}
|
|
84
|
+
const file = fields.file === undefined ? "ce-store.sqlite" : fields.file;
|
|
85
|
+
if (typeof file !== "string" || !STORE_FILE_PATTERN.test(file) || file.includes("..")) {
|
|
86
|
+
throw new McpBindingConfigError("store.file");
|
|
87
|
+
}
|
|
88
|
+
return Object.freeze({ directory, file });
|
|
89
|
+
}
|
|
90
|
+
function integer(value, field, min, max) {
|
|
91
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
|
|
92
|
+
throw new McpBindingConfigError(field);
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
function leasePolicy(value) {
|
|
97
|
+
if (value === undefined) {
|
|
98
|
+
return Object.freeze({ ttlSeconds: MCP_DEFAULT_LEASE_TTL_SECONDS, renewIntervalSeconds: MCP_DEFAULT_RENEW_INTERVAL_SECONDS });
|
|
99
|
+
}
|
|
100
|
+
const fields = plain(value, "lease", ["ttlSeconds", "renewIntervalSeconds"], []);
|
|
101
|
+
const ttlSeconds = fields.ttlSeconds === undefined
|
|
102
|
+
? MCP_DEFAULT_LEASE_TTL_SECONDS
|
|
103
|
+
: integer(fields.ttlSeconds, "lease.ttlSeconds", LEASE_MIN_TTL_SECONDS, LEASE_MAX_TTL_SECONDS);
|
|
104
|
+
// Renewal must happen at least twice per term so one missed tick cannot
|
|
105
|
+
// let a live process lose its lease.
|
|
106
|
+
const renewIntervalSeconds = fields.renewIntervalSeconds === undefined
|
|
107
|
+
? Math.min(MCP_DEFAULT_RENEW_INTERVAL_SECONDS, Math.floor(ttlSeconds / 2))
|
|
108
|
+
: integer(fields.renewIntervalSeconds, "lease.renewIntervalSeconds", 1, Math.floor(ttlSeconds / 2));
|
|
109
|
+
return Object.freeze({ ttlSeconds, renewIntervalSeconds });
|
|
110
|
+
}
|
|
111
|
+
function payloadReadPolicy(value) {
|
|
112
|
+
if (value === undefined)
|
|
113
|
+
return Object.freeze({ enabled: false });
|
|
114
|
+
const fields = plain(value, "payloadRead", ["enabled"], ["enabled"]);
|
|
115
|
+
if (typeof fields.enabled !== "boolean")
|
|
116
|
+
throw new McpBindingConfigError("payloadRead.enabled");
|
|
117
|
+
return Object.freeze({ enabled: fields.enabled });
|
|
118
|
+
}
|
|
119
|
+
/** HANDOFF-CE-E2B-01: the ACK opt-in is validated exactly like `payloadRead`.
|
|
120
|
+
* Absent means disabled; only a real boolean is accepted, and an unknown or
|
|
121
|
+
* accessor-backed field refuses with a structural path, never a value. */
|
|
122
|
+
function ackPolicy(value) {
|
|
123
|
+
if (value === undefined)
|
|
124
|
+
return Object.freeze({ enabled: false });
|
|
125
|
+
const fields = plain(value, "ack", ["enabled"], ["enabled"]);
|
|
126
|
+
if (typeof fields.enabled !== "boolean")
|
|
127
|
+
throw new McpBindingConfigError("ack.enabled");
|
|
128
|
+
return Object.freeze({ enabled: fields.enabled });
|
|
129
|
+
}
|
|
130
|
+
/** HANDOFF-CE-DIAG-06: the development execution guard opt-in is validated
|
|
131
|
+
* exactly like `ack`. Absent means disabled; only a real boolean is accepted,
|
|
132
|
+
* and an unknown or accessor-backed field refuses with a structural path. */
|
|
133
|
+
function debugExecutionGuardPolicy(value) {
|
|
134
|
+
if (value === undefined)
|
|
135
|
+
return Object.freeze({ enabled: false });
|
|
136
|
+
const fields = plain(value, "debugExecutionGuard", ["enabled"], ["enabled"]);
|
|
137
|
+
if (typeof fields.enabled !== "boolean")
|
|
138
|
+
throw new McpBindingConfigError("debugExecutionGuard.enabled");
|
|
139
|
+
return Object.freeze({ enabled: fields.enabled });
|
|
140
|
+
}
|
|
141
|
+
const MANAGED_TUNNEL_ID = /^tunnel_[A-Za-z0-9]{8,}$/;
|
|
142
|
+
const MANAGED_ORGANIZATION_ID = /^org-[A-Za-z0-9]{1,128}$/;
|
|
143
|
+
/** Managed provenance is validated with the same label discipline as every
|
|
144
|
+
* other field: strict identity shapes, no credential-shaped strings. */
|
|
145
|
+
function managedMetadata(value) {
|
|
146
|
+
if (value === undefined)
|
|
147
|
+
return undefined;
|
|
148
|
+
const fields = plain(value, "managed", ["tunnelId", "organizationId", "adapter"], ["tunnelId", "adapter"]);
|
|
149
|
+
const tunnelId = label(fields.tunnelId, "managed.tunnelId", LABEL_MAX.identity);
|
|
150
|
+
if (!MANAGED_TUNNEL_ID.test(tunnelId))
|
|
151
|
+
throw new McpBindingConfigError("managed.tunnelId");
|
|
152
|
+
const adapter = label(fields.adapter, "managed.adapter", LABEL_MAX.adapter);
|
|
153
|
+
const organizationId = fields.organizationId === undefined
|
|
154
|
+
? undefined
|
|
155
|
+
: label(fields.organizationId, "managed.organizationId", LABEL_MAX.identity);
|
|
156
|
+
if (organizationId !== undefined && !MANAGED_ORGANIZATION_ID.test(organizationId)) {
|
|
157
|
+
throw new McpBindingConfigError("managed.organizationId");
|
|
158
|
+
}
|
|
159
|
+
return Object.freeze({ tunnelId, ...(organizationId === undefined ? {} : { organizationId }), adapter });
|
|
160
|
+
}
|
|
161
|
+
/** Validate an already-parsed JSON value. Unknown fields, wrong types, unsafe
|
|
162
|
+
* paths, out-of-range lease settings, duplicate or self-referencing
|
|
163
|
+
* recipients, and credential-shaped strings are all refused. */
|
|
164
|
+
export function parseMcpBindingConfig(raw) {
|
|
165
|
+
const fields = plain(raw, "config", ["version", "store", "exchange", "endpoint", "recipients", "lease", "payloadRead", "ack", "debugExecutionGuard", "managed", "admissionPolicy"], ["version", "store", "exchange", "endpoint", "recipients"]);
|
|
166
|
+
if (fields.version !== MCP_BINDING_CONFIG_VERSION)
|
|
167
|
+
throw new McpBindingConfigError("config.version");
|
|
168
|
+
const store = storeLocation(fields.store);
|
|
169
|
+
const exchangeFields = plain(fields.exchange, "exchange", ["namespace", "idempotencyKey"], ["namespace", "idempotencyKey"]);
|
|
170
|
+
const exchange = Object.freeze({
|
|
171
|
+
namespace: label(exchangeFields.namespace, "exchange.namespace", LABEL_MAX.identity),
|
|
172
|
+
idempotencyKey: label(exchangeFields.idempotencyKey, "exchange.idempotencyKey", LABEL_MAX.identity),
|
|
173
|
+
});
|
|
174
|
+
const managed = fields.managed === undefined ? undefined : managedMetadata(fields.managed);
|
|
175
|
+
let admissionPolicy;
|
|
176
|
+
if (fields.admissionPolicy !== undefined) {
|
|
177
|
+
const ref = plain(fields.admissionPolicy, "admissionPolicy", ["kind", "profilePath"], ["kind", "profilePath"]);
|
|
178
|
+
const path = label(ref.profilePath, "admissionPolicy.profilePath", 4096);
|
|
179
|
+
if (ref.kind !== "ce-multi-session-v1" || !isAbsolute(path) || normalize(path) !== path || /[\u0000-\u001f\u007f]/.test(path))
|
|
180
|
+
throw new McpBindingConfigError("admissionPolicy");
|
|
181
|
+
admissionPolicy = Object.freeze({ kind: "ce-multi-session-v1", profilePath: path });
|
|
182
|
+
}
|
|
183
|
+
const endpoint = endpointRef(fields.endpoint, "endpoint");
|
|
184
|
+
if (!Array.isArray(fields.recipients))
|
|
185
|
+
throw new McpBindingConfigError("recipients");
|
|
186
|
+
const recipients = [];
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
fields.recipients.forEach((entry, index) => {
|
|
189
|
+
const ref = endpointRef(entry, `recipients[${index}]`);
|
|
190
|
+
if (ref.adapter === endpoint.adapter || ref.endpointId === endpoint.endpointId || seen.has(ref.endpointId)) {
|
|
191
|
+
throw new McpBindingConfigError(`recipients[${index}]`);
|
|
192
|
+
}
|
|
193
|
+
seen.add(ref.endpointId);
|
|
194
|
+
recipients.push(ref);
|
|
195
|
+
});
|
|
196
|
+
return Object.freeze({
|
|
197
|
+
store,
|
|
198
|
+
exchange,
|
|
199
|
+
endpoint,
|
|
200
|
+
recipients: Object.freeze(recipients),
|
|
201
|
+
lease: leasePolicy(fields.lease),
|
|
202
|
+
payloadRead: payloadReadPolicy(fields.payloadRead),
|
|
203
|
+
ack: ackPolicy(fields.ack),
|
|
204
|
+
debugExecutionGuard: debugExecutionGuardPolicy(fields.debugExecutionGuard),
|
|
205
|
+
...(managed === undefined ? {} : { managed }),
|
|
206
|
+
...(admissionPolicy === undefined ? {} : { admissionPolicy }),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=mcp-binding-config.js.map
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit operator-only stale ownership recovery.
|
|
3
|
+
*
|
|
4
|
+
* This is the production caller for CE-TAKEOVER-RECOVERY-CALLER-001. It is not
|
|
5
|
+
* reachable from MCP tools or model arguments. The caller must identify the
|
|
6
|
+
* exact durable owner it inspected; only an expired owner may be superseded.
|
|
7
|
+
* Recovery reuses the existing Session/capability/takeover/release/detach
|
|
8
|
+
* primitives and never edits ownership rows directly.
|
|
9
|
+
*/
|
|
10
|
+
import { bindSession, detachSession, generateSessionId, LEASE_DEFAULT_TTL_SECONDS, LEASE_MAX_TTL_SECONDS, LEASE_MIN_TTL_SECONDS, readCurrentOwnership, readFencingHighWater, readLeaseStatus, readSessionLifecycle, releaseOwnership, SESSION_ID_PATTERN, takeoverOwnership, } from "../storage/ownership.js";
|
|
11
|
+
import { issueCapability, readParticipantByEndpoint } from "../storage/participant.js";
|
|
12
|
+
const ERROR_DETAIL = {
|
|
13
|
+
CE_RECOVERY_INVALID_REQUEST: "recovery request rejected",
|
|
14
|
+
CE_RECOVERY_TARGET_MISMATCH: "configured endpoint does not match a durable Participant",
|
|
15
|
+
CE_RECOVERY_OWNER_ABSENT: "target endpoint has no ownership row",
|
|
16
|
+
CE_RECOVERY_OWNER_MISMATCH: "current ownership does not match the operator-confirmed evidence",
|
|
17
|
+
CE_RECOVERY_OWNER_ACTIVE: "current ownership lease is active; recovery refused",
|
|
18
|
+
CE_RECOVERY_CLOCK_REGRESSION: "operation clock precedes the persisted lease term",
|
|
19
|
+
CE_RECOVERY_RACE_LOST: "durable ownership changed during recovery; no retry performed",
|
|
20
|
+
CE_RECOVERY_BUSY: "durable store is contended; recovery rolled back",
|
|
21
|
+
CE_RECOVERY_OUTCOME_UNKNOWN: "recovery outcome is unknown; inspect durable state and do not retry",
|
|
22
|
+
CE_RECOVERY_FAILED: "recovery failed closed",
|
|
23
|
+
};
|
|
24
|
+
export class CeRecoveryError extends Error {
|
|
25
|
+
code;
|
|
26
|
+
retryable = false;
|
|
27
|
+
constructor(code) {
|
|
28
|
+
super(ERROR_DETAIL[code]);
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.name = "CeRecoveryError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function invalid() {
|
|
34
|
+
throw new CeRecoveryError("CE_RECOVERY_INVALID_REQUEST");
|
|
35
|
+
}
|
|
36
|
+
function plain(value, keys, required) {
|
|
37
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
38
|
+
invalid();
|
|
39
|
+
const prototype = Object.getPrototypeOf(value);
|
|
40
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
41
|
+
invalid();
|
|
42
|
+
const result = {};
|
|
43
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
44
|
+
if (typeof key !== "string" || !keys.includes(key))
|
|
45
|
+
invalid();
|
|
46
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
47
|
+
if (descriptor === undefined || !("value" in descriptor) || descriptor.enumerable !== true)
|
|
48
|
+
invalid();
|
|
49
|
+
result[key] = descriptor.value;
|
|
50
|
+
}
|
|
51
|
+
if (required.some((key) => !Object.hasOwn(result, key)))
|
|
52
|
+
invalid();
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
function label(value, maximum) {
|
|
56
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maximum || value.includes("\u0000"))
|
|
57
|
+
invalid();
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
function instant(value, nullable = false) {
|
|
61
|
+
if (nullable && value === null)
|
|
62
|
+
return null;
|
|
63
|
+
if (typeof value !== "string")
|
|
64
|
+
invalid();
|
|
65
|
+
const milliseconds = Date.parse(value);
|
|
66
|
+
if (!Number.isFinite(milliseconds) || new Date(milliseconds).toISOString() !== value)
|
|
67
|
+
invalid();
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
function normalizeRequest(input) {
|
|
71
|
+
const fields = plain(input, ["adapter", "endpointId", "expectedOwner", "leaseTtlSeconds"], ["adapter", "endpointId", "expectedOwner"]);
|
|
72
|
+
const owner = plain(fields.expectedOwner, ["sessionId", "fencingToken", "leaseStartedAt", "expiresAt"], ["sessionId", "fencingToken", "leaseStartedAt", "expiresAt"]);
|
|
73
|
+
if (typeof owner.sessionId !== "string" || !SESSION_ID_PATTERN.test(owner.sessionId))
|
|
74
|
+
invalid();
|
|
75
|
+
if (typeof owner.fencingToken !== "number" || !Number.isSafeInteger(owner.fencingToken) || owner.fencingToken < 1)
|
|
76
|
+
invalid();
|
|
77
|
+
const ttl = fields.leaseTtlSeconds === undefined ? LEASE_DEFAULT_TTL_SECONDS : fields.leaseTtlSeconds;
|
|
78
|
+
if (typeof ttl !== "number" ||
|
|
79
|
+
!Number.isSafeInteger(ttl) ||
|
|
80
|
+
ttl < LEASE_MIN_TTL_SECONDS ||
|
|
81
|
+
ttl > LEASE_MAX_TTL_SECONDS)
|
|
82
|
+
invalid();
|
|
83
|
+
return Object.freeze({
|
|
84
|
+
adapter: label(fields.adapter, 64),
|
|
85
|
+
endpointId: label(fields.endpointId, 256),
|
|
86
|
+
expectedOwner: Object.freeze({
|
|
87
|
+
sessionId: owner.sessionId,
|
|
88
|
+
fencingToken: owner.fencingToken,
|
|
89
|
+
leaseStartedAt: instant(owner.leaseStartedAt, true),
|
|
90
|
+
expiresAt: instant(owner.expiresAt),
|
|
91
|
+
}),
|
|
92
|
+
leaseTtlSeconds: ttl,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function exactOwner(actual, expected) {
|
|
96
|
+
return actual.sessionId === expected.sessionId &&
|
|
97
|
+
actual.fencingToken === expected.fencingToken &&
|
|
98
|
+
actual.leaseStartedAt === expected.leaseStartedAt &&
|
|
99
|
+
actual.expiresAt === expected.expiresAt;
|
|
100
|
+
}
|
|
101
|
+
function property(error, name) {
|
|
102
|
+
if (typeof error !== "object" || error === null)
|
|
103
|
+
return undefined;
|
|
104
|
+
return Object.getOwnPropertyDescriptor(error, name)?.value;
|
|
105
|
+
}
|
|
106
|
+
/** Classification valid ONLY before the takeover commits: every outcome here is
|
|
107
|
+
* either a refusal or a proven rollback. */
|
|
108
|
+
function mapPreWriteError(error) {
|
|
109
|
+
if (error instanceof CeRecoveryError)
|
|
110
|
+
return error;
|
|
111
|
+
if (property(error, "outcome") === "INDETERMINATE")
|
|
112
|
+
return new CeRecoveryError("CE_RECOVERY_OUTCOME_UNKNOWN");
|
|
113
|
+
const code = property(error, "code");
|
|
114
|
+
if (code === "OWNERSHIP_HELD" || code === "OWNERSHIP_TAKEOVER_INELIGIBLE" || code === "OWNERSHIP_TAKEOVER_REQUIRED") {
|
|
115
|
+
return new CeRecoveryError("CE_RECOVERY_RACE_LOST");
|
|
116
|
+
}
|
|
117
|
+
if (property(error, "retryable") === true && property(error, "outcome") === "ROLLED_BACK") {
|
|
118
|
+
return new CeRecoveryError("CE_RECOVERY_BUSY");
|
|
119
|
+
}
|
|
120
|
+
return new CeRecoveryError("CE_RECOVERY_FAILED");
|
|
121
|
+
}
|
|
122
|
+
function cleanupKnownOrphan(store, participantId, sessionId, resumeCapability, seams) {
|
|
123
|
+
try {
|
|
124
|
+
detachSession(store, { participantId, sessionId, capability: resumeCapability }, seams);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Best effort only: the primary refusal stays authoritative, exactly as the
|
|
128
|
+
// shared host seam does for a refused start's orphan Session.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Recover one exact expired owner and leave the Participant UNOWNED so its normal
|
|
133
|
+
* host can acquire a fresh, strictly greater fencing token. The structured
|
|
134
|
+
* receipt is operator evidence only; it contains identifiers and term evidence,
|
|
135
|
+
* never capability plaintext or the host secret.
|
|
136
|
+
*/
|
|
137
|
+
export function recoverExpiredOwnership(store, input, seams = {}) {
|
|
138
|
+
const request = normalizeRequest(input);
|
|
139
|
+
// Preflight reads stay inside the typed surface: a contended or corrupt store
|
|
140
|
+
// must not escape as a raw storage error with a misleading CLI diagnosis.
|
|
141
|
+
let participant;
|
|
142
|
+
let previous;
|
|
143
|
+
let status;
|
|
144
|
+
try {
|
|
145
|
+
participant = readParticipantByEndpoint(store, request.adapter, request.endpointId);
|
|
146
|
+
if (participant === undefined)
|
|
147
|
+
throw new CeRecoveryError("CE_RECOVERY_TARGET_MISMATCH");
|
|
148
|
+
previous = readCurrentOwnership(store, participant.participantId);
|
|
149
|
+
status = previous === undefined
|
|
150
|
+
? undefined
|
|
151
|
+
: readLeaseStatus(store, { participantId: participant.participantId }, seams);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
throw mapPreWriteError(error);
|
|
155
|
+
}
|
|
156
|
+
if (previous === undefined)
|
|
157
|
+
throw new CeRecoveryError("CE_RECOVERY_OWNER_ABSENT");
|
|
158
|
+
if (!exactOwner(previous, request.expectedOwner))
|
|
159
|
+
throw new CeRecoveryError("CE_RECOVERY_OWNER_MISMATCH");
|
|
160
|
+
if (status === "ACTIVE")
|
|
161
|
+
throw new CeRecoveryError("CE_RECOVERY_OWNER_ACTIVE");
|
|
162
|
+
if (status === "CLOCK_REGRESSION")
|
|
163
|
+
throw new CeRecoveryError("CE_RECOVERY_CLOCK_REGRESSION");
|
|
164
|
+
if (status !== "EXPIRED")
|
|
165
|
+
throw new CeRecoveryError("CE_RECOVERY_OWNER_ABSENT");
|
|
166
|
+
const recoverySessionId = (seams.sessionId ?? generateSessionId)();
|
|
167
|
+
if (!SESSION_ID_PATTERN.test(recoverySessionId))
|
|
168
|
+
invalid();
|
|
169
|
+
let resumeCapability;
|
|
170
|
+
try {
|
|
171
|
+
const attachCapability = issueCapability(store, { participantId: participant.participantId, scope: "attach" }, seams).capability;
|
|
172
|
+
bindSession(store, { participantId: participant.participantId, sessionId: recoverySessionId, capability: attachCapability }, seams);
|
|
173
|
+
resumeCapability = issueCapability(store, { participantId: participant.participantId, scope: "resume", sessionId: recoverySessionId }, seams).capability;
|
|
174
|
+
const takeoverCapability = issueCapability(store, { participantId: participant.participantId, scope: "takeover", sessionId: recoverySessionId }, seams).capability;
|
|
175
|
+
let takeover;
|
|
176
|
+
try {
|
|
177
|
+
takeover = takeoverOwnership(store, {
|
|
178
|
+
participantId: participant.participantId,
|
|
179
|
+
sessionId: recoverySessionId,
|
|
180
|
+
capability: takeoverCapability,
|
|
181
|
+
leaseTtlSeconds: request.leaseTtlSeconds,
|
|
182
|
+
}, seams);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
const mapped = mapPreWriteError(error);
|
|
186
|
+
if (mapped.code !== "CE_RECOVERY_OUTCOME_UNKNOWN") {
|
|
187
|
+
cleanupKnownOrphan(store, participant.participantId, recoverySessionId, resumeCapability, seams);
|
|
188
|
+
}
|
|
189
|
+
throw mapped;
|
|
190
|
+
}
|
|
191
|
+
// The takeover has committed. From here no failure is a proven rollback, so
|
|
192
|
+
// every later problem is an unknown outcome requiring operator inspection:
|
|
193
|
+
// reporting "rolled back" or "failed closed" would be untrue.
|
|
194
|
+
let released;
|
|
195
|
+
let detached;
|
|
196
|
+
let highWater;
|
|
197
|
+
try {
|
|
198
|
+
seams.afterTakeover?.();
|
|
199
|
+
released = releaseOwnership(store, {
|
|
200
|
+
participantId: participant.participantId,
|
|
201
|
+
sessionId: recoverySessionId,
|
|
202
|
+
fencingToken: takeover.fencingToken,
|
|
203
|
+
capability: resumeCapability,
|
|
204
|
+
}, seams);
|
|
205
|
+
detached = detachSession(store, { participantId: participant.participantId, sessionId: recoverySessionId, capability: resumeCapability }, seams);
|
|
206
|
+
const finalOwner = readCurrentOwnership(store, participant.participantId);
|
|
207
|
+
const lifecycle = readSessionLifecycle(store, { participantId: participant.participantId, sessionId: recoverySessionId });
|
|
208
|
+
highWater = readFencingHighWater(store, participant.participantId);
|
|
209
|
+
if (finalOwner !== undefined ||
|
|
210
|
+
lifecycle.state !== "DETACHED" ||
|
|
211
|
+
lifecycle.detachedAt === undefined ||
|
|
212
|
+
highWater !== takeover.fencingToken) {
|
|
213
|
+
throw new CeRecoveryError("CE_RECOVERY_OUTCOME_UNKNOWN");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
throw new CeRecoveryError("CE_RECOVERY_OUTCOME_UNKNOWN");
|
|
218
|
+
}
|
|
219
|
+
return Object.freeze({
|
|
220
|
+
version: 1,
|
|
221
|
+
disposition: "RECOVERED",
|
|
222
|
+
endpoint: Object.freeze({ adapter: request.adapter, endpointId: request.endpointId }),
|
|
223
|
+
participantId: participant.participantId,
|
|
224
|
+
supersededOwner: request.expectedOwner,
|
|
225
|
+
recoverySessionId,
|
|
226
|
+
takeover: Object.freeze({
|
|
227
|
+
fencingToken: takeover.fencingToken,
|
|
228
|
+
leaseStartedAt: takeover.leaseStartedAt,
|
|
229
|
+
expiresAt: takeover.expiresAt,
|
|
230
|
+
}),
|
|
231
|
+
releasedAt: released.releasedAt,
|
|
232
|
+
detachedAt: detached.detachedAt,
|
|
233
|
+
final: Object.freeze({
|
|
234
|
+
ownership: "UNOWNED",
|
|
235
|
+
fencingHighWater: highWater,
|
|
236
|
+
recoverySession: "DETACHED",
|
|
237
|
+
}),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (error instanceof CeRecoveryError)
|
|
242
|
+
throw error;
|
|
243
|
+
if (resumeCapability !== undefined) {
|
|
244
|
+
try {
|
|
245
|
+
const current = readCurrentOwnership(store, participant.participantId);
|
|
246
|
+
if (current === undefined || current.sessionId !== recoverySessionId) {
|
|
247
|
+
cleanupKnownOrphan(store, participant.participantId, recoverySessionId, resumeCapability, seams);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// Best effort only: the original failure stays authoritative.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
throw mapPreWriteError(error);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=ownership-recovery.js.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public library surface of pi-chat-exchange: the frozen protocol contracts
|
|
3
|
+
* and the pure core rules (plus the in-memory idempotency reference).
|
|
4
|
+
* Control surfaces and endpoint integrations are internal seams, not re-exported;
|
|
5
|
+
* `src/bin` composes the MCP smoke entrypoint.
|
|
6
|
+
*/
|
|
7
|
+
export * from "./protocol/index.js";
|
|
8
|
+
export * from "./core/rules.js";
|
|
9
|
+
export * from "./core/reference.js";
|
|
10
|
+
export * from "./core/authorization.js";
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action/capability contracts (PH-0003). Endpoint peers have no protocol role.
|
|
3
|
+
* These plain contexts are TRUSTED HOST INPUT, not self-issued credentials.
|
|
4
|
+
* The future host authenticates identity and loads grants transactionally.
|
|
5
|
+
* Interface implementation and support advertisement confer no authority.
|
|
6
|
+
*/
|
|
7
|
+
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import { EndpointRefSchema } from "./entities.js";
|
|
9
|
+
import { EXCHANGE_ID_PATTERN, MESSAGE_ID_PATTERN, ATTEMPT_ID_PATTERN } from "./identity.js";
|
|
10
|
+
const ExchangeIdSchema = Type.String({ pattern: EXCHANGE_ID_PATTERN.source });
|
|
11
|
+
const MessageIdSchema = Type.String({ pattern: MESSAGE_ID_PATTERN.source });
|
|
12
|
+
const AttemptIdSchema = Type.String({ pattern: ATTEMPT_ID_PATTERN.source });
|
|
13
|
+
export const CONTROL_OPERATIONS = ["exchange_send", "exchange_get", "exchange_list", "exchange_cancel", "exchange_result"];
|
|
14
|
+
export const OPTIONAL_CONTROL_CAPABILITIES = ["exchange_close", "exchange_archive"];
|
|
15
|
+
export const ENDPOINT_OPERATIONS = ["message_claim", "dispatch_mark_started", "delivery_report", "activity_report", "settlement_report", "result_publish", "lease_release", "ambiguity_resolve", "cancel_confirm"];
|
|
16
|
+
const strict = { additionalProperties: false };
|
|
17
|
+
const principal = Type.String({ minLength: 1, maxLength: 256 });
|
|
18
|
+
const collectionScope = Type.Object({ kind: Type.Literal("collection") }, strict);
|
|
19
|
+
const exchangeScope = Type.Object({ kind: Type.Literal("exchange"), exchangeId: ExchangeIdSchema }, strict);
|
|
20
|
+
const ownerScope = Type.Object({ kind: Type.Literal("owner"), exchangeId: ExchangeIdSchema, ownerId: principal }, strict);
|
|
21
|
+
const messageScope = Type.Object({ kind: Type.Literal("message"), exchangeId: ExchangeIdSchema, messageId: MessageIdSchema }, strict);
|
|
22
|
+
const destinationScope = Type.Object({ kind: Type.Literal("destination"), exchangeId: ExchangeIdSchema, messageId: MessageIdSchema, endpoint: EndpointRefSchema }, strict);
|
|
23
|
+
const attemptScope = Type.Object({ kind: Type.Literal("attempt"), exchangeId: ExchangeIdSchema, messageId: MessageIdSchema, attemptId: AttemptIdSchema, endpoint: EndpointRefSchema }, strict);
|
|
24
|
+
const actions = (names) => Type.Union(names.map((name) => Type.Literal(name)));
|
|
25
|
+
export const ActionGrantSchema = Type.Union([
|
|
26
|
+
Type.Object({ action: actions(["exchange_send", "exchange_get", "exchange_result"]), principalId: principal, scope: exchangeScope }, strict),
|
|
27
|
+
Type.Object({ action: Type.Literal("exchange_list"), principalId: principal, scope: collectionScope }, strict),
|
|
28
|
+
Type.Object({ action: actions(OPTIONAL_CONTROL_CAPABILITIES), principalId: principal, scope: ownerScope }, strict),
|
|
29
|
+
Type.Object({ action: Type.Literal("exchange_cancel"), principalId: principal, scope: messageScope }, strict),
|
|
30
|
+
Type.Object({ action: Type.Literal("message_claim"), principalId: principal, scope: destinationScope }, strict),
|
|
31
|
+
Type.Object({ action: actions(ENDPOINT_OPERATIONS.filter((action) => action !== "message_claim")), principalId: principal, scope: attemptScope }, strict),
|
|
32
|
+
]);
|
|
33
|
+
export const AuthorizationContextSchema = Type.Object({ principalId: principal, grants: Type.Array(ActionGrantSchema) }, strict);
|
|
34
|
+
export function isControlOperation(name) {
|
|
35
|
+
return CONTROL_OPERATIONS.includes(name);
|
|
36
|
+
}
|
|
37
|
+
export function isEndpointOperation(name) {
|
|
38
|
+
return ENDPOINT_OPERATIONS.includes(name);
|
|
39
|
+
}
|
|
40
|
+
/** Catalog separation is a development check, not host authorization. */
|
|
41
|
+
export function assertOperationSeparation() {
|
|
42
|
+
if ([...CONTROL_OPERATIONS, ...OPTIONAL_CONTROL_CAPABILITIES].some(isEndpointOperation)) {
|
|
43
|
+
throw new Error("control and endpoint action catalogs overlap");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=capabilities.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Support advertisements are not evidence or authorization. No runtime binding. */
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
export const CapabilitySupportSchema = Type.Union([Type.Literal("supported"), Type.Literal("unsupported"), Type.Literal("unknown")]);
|
|
4
|
+
export const EndpointCapabilitiesSchema = Type.Object({
|
|
5
|
+
delivery: CapabilitySupportSchema,
|
|
6
|
+
observation: Type.Optional(CapabilitySupportSchema),
|
|
7
|
+
cancellation: Type.Optional(CapabilitySupportSchema),
|
|
8
|
+
followup: Type.Optional(CapabilitySupportSchema),
|
|
9
|
+
resultProduction: Type.Optional(CapabilitySupportSchema),
|
|
10
|
+
}, { additionalProperties: false });
|
|
11
|
+
/** Missing support remains UNKNOWN. Nothing here creates a receipt or runtime fact. */
|
|
12
|
+
export function endpointCapabilitySupport(capabilities, capability) {
|
|
13
|
+
return capabilities[capability] ?? "unknown";
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=endpoint-capabilities.js.map
|