@clowder-ai/plugin-sdk 0.1.0-beta.10
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/dist/contract-mirror.d.ts +120 -0
- package/dist/contract-mirror.d.ts.map +1 -0
- package/dist/contract-mirror.js +179 -0
- package/dist/contract-mirror.js.map +1 -0
- package/dist/events-publisher.d.ts +34 -0
- package/dist/events-publisher.d.ts.map +1 -0
- package/dist/events-publisher.js +62 -0
- package/dist/events-publisher.js.map +1 -0
- package/dist/feature-context.d.ts +90 -0
- package/dist/feature-context.d.ts.map +1 -0
- package/dist/feature-context.js +210 -0
- package/dist/feature-context.js.map +1 -0
- package/dist/handshake-client.d.ts +66 -0
- package/dist/handshake-client.d.ts.map +1 -0
- package/dist/handshake-client.js +124 -0
- package/dist/handshake-client.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/standalone-host.d.ts +55 -0
- package/dist/standalone-host.d.ts.map +1 -0
- package/dist/standalone-host.js +249 -0
- package/dist/standalone-host.js.map +1 -0
- package/dist/stdio-runtime.d.ts +50 -0
- package/dist/stdio-runtime.d.ts.map +1 -0
- package/dist/stdio-runtime.js +294 -0
- package/dist/stdio-runtime.js.map +1 -0
- package/dist/wire-dispatch.d.ts +43 -0
- package/dist/wire-dispatch.d.ts.map +1 -0
- package/dist/wire-dispatch.js +983 -0
- package/dist/wire-dispatch.js.map +1 -0
- package/package.json +48 -0
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire dispatch classifier — pre-dispatch frame classification per the
|
|
3
|
+
* disposition table (T-A through T-M, §3.8-1 plus beta.8 closure).
|
|
4
|
+
*
|
|
5
|
+
* This module classifies decoded NDJSON frames into one of the 13
|
|
6
|
+
* disposition classes. T-A (transport failure) and T-B (JSON parse error)
|
|
7
|
+
* are handled by the NDJSON frame decoder layer — this classifier covers
|
|
8
|
+
* T-C through T-M.
|
|
9
|
+
*
|
|
10
|
+
* A frame that passes all rejection checks returns disposition=null with
|
|
11
|
+
* outcome='accept', indicating a valid request that should be dispatched
|
|
12
|
+
* to a method handler.
|
|
13
|
+
*
|
|
14
|
+
* This module defines nothing new — every classification rule traces to
|
|
15
|
+
* the frozen disposition table in @clowder-ai/plugin-contract.
|
|
16
|
+
*/
|
|
17
|
+
import { validateRequestId, hasHandshakeAuthorityInjection, validateCandidateHello, validateBrokerReadyParams, validateSessionBinding, validateEffectiveGrants, validateEventsPublishInput, validateEventsPublishResult, validateMessagingRowInput, validateMessagingRowResult, isWireMethod, isWireUInt53, isCanonicalUInt53Token, NOTIFICATION_METHODS, MESSAGING_ROW_METHODS, WIRE_METHOD_REGISTRY, INVALID_REQUEST_CODE, INVALID_REQUEST_MESSAGE, METHOD_NOT_FOUND_CODE, METHOD_NOT_FOUND_MESSAGE, INVALID_PARAMS_CODE, INVALID_PARAMS_MESSAGE, PING_NONCE_MIN_LENGTH, PING_NONCE_MAX_LENGTH, ALL_ERROR_CODES, APPLICATION_ERROR_CODES, ERROR_CODE_TO_MESSAGE,
|
|
18
|
+
// Per-arm application error codes (for data schema dispatch)
|
|
19
|
+
HANDSHAKE_REJECTED_CODE, HANDSHAKE_REJECTED_MESSAGE, DELIVERY_REJECTED_CODE, DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE, SNAPSHOT_UNAVAILABLE_CODE,
|
|
20
|
+
// Standard error code (ParseError null-id arm validation)
|
|
21
|
+
PARSE_ERROR_CODE,
|
|
22
|
+
// Reject-reason closed enums (application error data validation)
|
|
23
|
+
HANDSHAKE_REJECT_REASONS, DELIVERY_REJECT_REASONS, SNAPSHOT_UNAVAILABLE_REASONS, } from '@clowder-ai/plugin-contract';
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Error response builders
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
function respondInvalidRequestNull() {
|
|
28
|
+
return {
|
|
29
|
+
disposition: 'T-D',
|
|
30
|
+
outcome: 'respond',
|
|
31
|
+
response: {
|
|
32
|
+
jsonrpc: '2.0',
|
|
33
|
+
id: null,
|
|
34
|
+
error: { code: INVALID_REQUEST_CODE, message: INVALID_REQUEST_MESSAGE },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function respondInvalidRequestId(id) {
|
|
39
|
+
return {
|
|
40
|
+
disposition: 'T-F',
|
|
41
|
+
outcome: 'respond',
|
|
42
|
+
response: {
|
|
43
|
+
jsonrpc: '2.0',
|
|
44
|
+
id,
|
|
45
|
+
error: { code: INVALID_REQUEST_CODE, message: INVALID_REQUEST_MESSAGE },
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function respondMethodNotFound(id) {
|
|
50
|
+
return {
|
|
51
|
+
disposition: 'T-F',
|
|
52
|
+
outcome: 'respond',
|
|
53
|
+
response: {
|
|
54
|
+
jsonrpc: '2.0',
|
|
55
|
+
id,
|
|
56
|
+
error: { code: METHOD_NOT_FOUND_CODE, message: METHOD_NOT_FOUND_MESSAGE },
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function respondInvalidParams(id) {
|
|
61
|
+
return {
|
|
62
|
+
disposition: 'T-F',
|
|
63
|
+
outcome: 'respond',
|
|
64
|
+
response: {
|
|
65
|
+
jsonrpc: '2.0',
|
|
66
|
+
id,
|
|
67
|
+
error: { code: INVALID_PARAMS_CODE, message: INVALID_PARAMS_MESSAGE },
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function respondInvalidParamsValue(id) {
|
|
72
|
+
return {
|
|
73
|
+
disposition: 'T-G',
|
|
74
|
+
outcome: 'respond',
|
|
75
|
+
response: {
|
|
76
|
+
jsonrpc: '2.0',
|
|
77
|
+
id,
|
|
78
|
+
error: { code: INVALID_PARAMS_CODE, message: INVALID_PARAMS_MESSAGE },
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function respondHandshakeAuthorityViolation(id) {
|
|
83
|
+
return {
|
|
84
|
+
disposition: 'T-G',
|
|
85
|
+
outcome: 'respond',
|
|
86
|
+
response: {
|
|
87
|
+
jsonrpc: '2.0',
|
|
88
|
+
id,
|
|
89
|
+
error: {
|
|
90
|
+
code: HANDSHAKE_REJECTED_CODE,
|
|
91
|
+
message: HANDSHAKE_REJECTED_MESSAGE,
|
|
92
|
+
data: { reason: 'AUTHORITY_VIOLATION' },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function close(disposition) {
|
|
98
|
+
return { disposition, outcome: 'close' };
|
|
99
|
+
}
|
|
100
|
+
function accept(disposition) {
|
|
101
|
+
return { disposition, outcome: 'accept' };
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Non-scalar string detection (T-C canonicality, lone surrogate check)
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
/**
|
|
107
|
+
* Returns true if any string value or object key in the parsed JSON tree
|
|
108
|
+
* contains a lone surrogate (U+D800–U+DFFF). These are non-scalar strings
|
|
109
|
+
* per the Unicode specification and fail the T-C canonicality predicate.
|
|
110
|
+
*
|
|
111
|
+
* Lone surrogates roundtrip through JSON.stringify (ES2019+ escapes them
|
|
112
|
+
* as \uXXXX), so byte-equality alone cannot catch them.
|
|
113
|
+
*/
|
|
114
|
+
function containsNonScalarString(value) {
|
|
115
|
+
if (typeof value === 'string') {
|
|
116
|
+
for (let i = 0; i < value.length; i++) {
|
|
117
|
+
const c = value.charCodeAt(i);
|
|
118
|
+
if (c >= 0xD800 && c <= 0xDBFF) {
|
|
119
|
+
// High surrogate — must be followed by a low surrogate (U+DC00–U+DFFF)
|
|
120
|
+
const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0;
|
|
121
|
+
if (next < 0xDC00 || next > 0xDFFF)
|
|
122
|
+
return true;
|
|
123
|
+
i++; // Skip the valid low surrogate pair partner
|
|
124
|
+
}
|
|
125
|
+
else if (c >= 0xDC00 && c <= 0xDFFF) {
|
|
126
|
+
// Lone low surrogate (not preceded by a high surrogate)
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (value === null || typeof value !== 'object')
|
|
133
|
+
return false;
|
|
134
|
+
if (Array.isArray(value)) {
|
|
135
|
+
for (const item of value) {
|
|
136
|
+
if (containsNonScalarString(item))
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
for (const key of Object.keys(value)) {
|
|
142
|
+
if (containsNonScalarString(key))
|
|
143
|
+
return true;
|
|
144
|
+
if (containsNonScalarString(value[key]))
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// WireUInt53 raw-token validation (P1-1 maintainer requirement)
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
/**
|
|
153
|
+
* Check whether any WireUInt53 position in the parsed frame value has a
|
|
154
|
+
* numeric token whose V8-canonical form violates the WireUInt53 raw grammar
|
|
155
|
+
* (0|[1-9][0-9]{0,15}, no sign, no decimal, no exponent).
|
|
156
|
+
*
|
|
157
|
+
* After byte-equality passes (rawStr === JSON.stringify(value)), the raw
|
|
158
|
+
* token at any numeric position IS String(parsedValue). So we walk the
|
|
159
|
+
* parsed structure to WireUInt53 positions and validate String(n) against
|
|
160
|
+
* isCanonicalUInt53Token. This catches negative integers (-1), fractions
|
|
161
|
+
* (1.5), and oversized values (>2^53-1) at the T-C layer, before any
|
|
162
|
+
* method-specific validation runs.
|
|
163
|
+
*
|
|
164
|
+
* WireUInt53 positions in the frozen schema:
|
|
165
|
+
* - params.meta.deadlineUnixMs (every request/notification)
|
|
166
|
+
* - params.input.deadlineUnixMs (host.lifecycle.drain input)
|
|
167
|
+
* - params.input.grantRevision (host.grants.changed input)
|
|
168
|
+
* - params.input.baseRevision (messaging.appendElements input)
|
|
169
|
+
* - params.input.limit (messaging.read input)
|
|
170
|
+
* - params.input.maxItems (messaging.snapshot input)
|
|
171
|
+
* - params.input.envelope.revision (host.messaging.deliver input)
|
|
172
|
+
* - result.grantRevision (broker.hello SessionBinding)
|
|
173
|
+
* - result.revision (messaging.send, messaging.appendElements)
|
|
174
|
+
* - result.publishSequence (messaging.send)
|
|
175
|
+
* - result.appendSequence (messaging.appendElements)
|
|
176
|
+
* - result.events[].sequence (messaging.read)
|
|
177
|
+
* - result.items[].revision (messaging.snapshot)
|
|
178
|
+
*/
|
|
179
|
+
function hasNonCanonicalUInt53Token(value, inFlight) {
|
|
180
|
+
const requestMethod = typeof value.method === 'string'
|
|
181
|
+
? value.method
|
|
182
|
+
: undefined;
|
|
183
|
+
const params = value.params;
|
|
184
|
+
if (requestMethod !== undefined && params !== null && typeof params === 'object' && !Array.isArray(params)) {
|
|
185
|
+
const paramsObj = params;
|
|
186
|
+
// ── params.meta.deadlineUnixMs ──
|
|
187
|
+
const meta = paramsObj.meta;
|
|
188
|
+
if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
|
|
189
|
+
const metaObj = meta;
|
|
190
|
+
if (typeof metaObj.deadlineUnixMs === 'number') {
|
|
191
|
+
if (!isCanonicalUInt53Token(String(metaObj.deadlineUnixMs)))
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// ── method-owned input WireUInt53 leaves ──
|
|
196
|
+
const input = paramsObj.input;
|
|
197
|
+
if (input !== null && typeof input === 'object' && !Array.isArray(input)) {
|
|
198
|
+
const inputObj = input;
|
|
199
|
+
if (requestMethod === 'host.lifecycle.drain' && typeof inputObj.deadlineUnixMs === 'number') {
|
|
200
|
+
if (!isCanonicalUInt53Token(String(inputObj.deadlineUnixMs)))
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
if (requestMethod === 'host.grants.changed' && typeof inputObj.grantRevision === 'number') {
|
|
204
|
+
if (!isCanonicalUInt53Token(String(inputObj.grantRevision)))
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
// ── M0-C messaging request WireUInt53 leaves ──
|
|
208
|
+
if (requestMethod === 'messaging.appendElements' && typeof inputObj.baseRevision === 'number') {
|
|
209
|
+
if (!isCanonicalUInt53Token(String(inputObj.baseRevision)))
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
if (requestMethod === 'messaging.read' && typeof inputObj.limit === 'number') {
|
|
213
|
+
if (!isCanonicalUInt53Token(String(inputObj.limit)))
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
if (requestMethod === 'messaging.snapshot' && typeof inputObj.maxItems === 'number') {
|
|
217
|
+
if (!isCanonicalUInt53Token(String(inputObj.maxItems)))
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
if (requestMethod === 'host.messaging.deliver') {
|
|
221
|
+
const envelope = inputObj.envelope;
|
|
222
|
+
if (envelope !== null && typeof envelope === 'object' && !Array.isArray(envelope)) {
|
|
223
|
+
const envelopeObj = envelope;
|
|
224
|
+
if (typeof envelopeObj.revision === 'number') {
|
|
225
|
+
if (!isCanonicalUInt53Token(String(envelopeObj.revision)))
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Response-side WireUInt53 positions: consult the correlated in-flight
|
|
233
|
+
// row before applying raw-token gates. Only protocol-defined numeric
|
|
234
|
+
// leaves at known positions are checked — open payloads are not covered.
|
|
235
|
+
const result = value.result;
|
|
236
|
+
const responseMethod = typeof value.id === 'string'
|
|
237
|
+
? inFlight.get(value.id)?.method
|
|
238
|
+
: undefined;
|
|
239
|
+
if (!('method' in value) && result !== null && typeof result === 'object' && !Array.isArray(result)) {
|
|
240
|
+
const resultObj = result;
|
|
241
|
+
// H7: broker.hello SessionBinding.grantRevision
|
|
242
|
+
if (responseMethod === 'broker.hello') {
|
|
243
|
+
if (typeof resultObj.grantRevision === 'number') {
|
|
244
|
+
if (!isCanonicalUInt53Token(String(resultObj.grantRevision)))
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// messaging.send: result.revision, result.publishSequence
|
|
249
|
+
if (responseMethod === 'messaging.send') {
|
|
250
|
+
if (typeof resultObj.revision === 'number' && !isCanonicalUInt53Token(String(resultObj.revision)))
|
|
251
|
+
return true;
|
|
252
|
+
if (typeof resultObj.publishSequence === 'number' && !isCanonicalUInt53Token(String(resultObj.publishSequence)))
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
// messaging.appendElements: result.revision, result.appendSequence
|
|
256
|
+
if (responseMethod === 'messaging.appendElements') {
|
|
257
|
+
if (typeof resultObj.revision === 'number' && !isCanonicalUInt53Token(String(resultObj.revision)))
|
|
258
|
+
return true;
|
|
259
|
+
if (typeof resultObj.appendSequence === 'number' && !isCanonicalUInt53Token(String(resultObj.appendSequence)))
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
// messaging.read: result.events[] WireUInt53 leaves
|
|
263
|
+
// Common: events[].sequence
|
|
264
|
+
// Publish arm: events[].envelope.revision (not traversing open payload)
|
|
265
|
+
// Elements-append arm: events[].revision, events[].baseRevision
|
|
266
|
+
if (responseMethod === 'messaging.read' && Array.isArray(resultObj.events)) {
|
|
267
|
+
for (const event of resultObj.events) {
|
|
268
|
+
if (event !== null && typeof event === 'object' && !Array.isArray(event)) {
|
|
269
|
+
const eventObj = event;
|
|
270
|
+
if (typeof eventObj.sequence === 'number' && !isCanonicalUInt53Token(String(eventObj.sequence)))
|
|
271
|
+
return true;
|
|
272
|
+
// publish arm: envelope.revision
|
|
273
|
+
const eventEnvelope = eventObj.envelope;
|
|
274
|
+
if (eventEnvelope !== null && typeof eventEnvelope === 'object' && !Array.isArray(eventEnvelope)) {
|
|
275
|
+
const envObj = eventEnvelope;
|
|
276
|
+
if (typeof envObj.revision === 'number' && !isCanonicalUInt53Token(String(envObj.revision)))
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
// elements-append arm: revision, baseRevision
|
|
280
|
+
if (typeof eventObj.revision === 'number' && !isCanonicalUInt53Token(String(eventObj.revision)))
|
|
281
|
+
return true;
|
|
282
|
+
if (typeof eventObj.baseRevision === 'number' && !isCanonicalUInt53Token(String(eventObj.baseRevision)))
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// messaging.snapshot: result.items[].revision
|
|
288
|
+
if (responseMethod === 'messaging.snapshot' && Array.isArray(resultObj.items)) {
|
|
289
|
+
for (const item of resultObj.items) {
|
|
290
|
+
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
|
|
291
|
+
const itemObj = item;
|
|
292
|
+
if (typeof itemObj.revision === 'number' && !isCanonicalUInt53Token(String(itemObj.revision)))
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// Contract-mirror imports (key sets from contract-mirror.ts)
|
|
302
|
+
//
|
|
303
|
+
// These mirror contract type-level constraints (additionalProperties: false)
|
|
304
|
+
// that lack runtime exports. Each is drift-tested in contract-mirror.test.ts.
|
|
305
|
+
// See contract-mirror.ts for deletion schedule and anchoring.
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
import { MESSAGING_ERROR_CODE_SET, RESPONSE_SUCCESS_KEYS, RESPONSE_ERROR_KEYS, NOTIFICATION_ALLOWED_KEYS, REQUEST_ALLOWED_KEYS, PARAMS_ALLOWED_KEYS, META_ALLOWED_KEYS, PING_INPUT_KEYS, DRAIN_INPUT_KEYS, GRANTS_CHANGED_INPUT_KEYS, PING_RESULT_KEYS, ERROR_BODY_STANDARD_KEYS, ERROR_BODY_APPLICATION_KEYS, REASON_DATA_KEYS, CODE_DATA_KEYS, } from './contract-mirror.js';
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Derived constants (built from contract runtime imports, NOT mirrors)
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Error code validation sets (built from contract arrays).
|
|
312
|
+
const KNOWN_ERROR_CODES = new Set(ALL_ERROR_CODES);
|
|
313
|
+
const APPLICATION_CODES = new Set(APPLICATION_ERROR_CODES);
|
|
314
|
+
// Reason enum sets (built from contract arrays).
|
|
315
|
+
const HANDSHAKE_REASONS = new Set(HANDSHAKE_REJECT_REASONS);
|
|
316
|
+
const DELIVERY_REASONS = new Set(DELIVERY_REJECT_REASONS);
|
|
317
|
+
const SNAPSHOT_REASONS = new Set(SNAPSHOT_UNAVAILABLE_REASONS);
|
|
318
|
+
const MESSAGING_METHODS = new Set(MESSAGING_ROW_METHODS);
|
|
319
|
+
function isMessagingRowMethod(method) {
|
|
320
|
+
return MESSAGING_METHODS.has(method);
|
|
321
|
+
}
|
|
322
|
+
// Standard error codes that mandate null id (not string RequestId).
|
|
323
|
+
// ParseError (-32700) ALWAYS has id: null per the contract envelope.
|
|
324
|
+
// If we reach the error validation path, id is already a valid string
|
|
325
|
+
// (verified upstream), so ParseError with string id is invalid.
|
|
326
|
+
const NULL_ID_ERROR_CODES = new Set([PARSE_ERROR_CODE]);
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Per-method application error allowlists (frozen per-row registry)
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
//
|
|
331
|
+
// Every registry row's application-error set resolves through the closed
|
|
332
|
+
// application table in #1165. Eligibility is keyed off the row, NOT
|
|
333
|
+
// leafClosure — eligibility is a per-row contract property.
|
|
334
|
+
//
|
|
335
|
+
// Rows 1-2 (broker.hello/ready): HANDSHAKE_REJECTED
|
|
336
|
+
// Rows 3-7 (messaging send/append/sub/read/ack): DOMAIN_ERROR, DEADLINE_EXPIRED
|
|
337
|
+
// Row 8 (messaging.snapshot): DOMAIN_ERROR, DEADLINE_EXPIRED, SNAPSHOT_UNAVAILABLE
|
|
338
|
+
// Row 9 (host.messaging.deliver): DELIVERY_REJECTED
|
|
339
|
+
// Row 10 (host.grants.changed): notification-only (no response)
|
|
340
|
+
// Row 11 (host.lifecycle.ping): standard only (no application errors)
|
|
341
|
+
// Row 12 (host.lifecycle.drain): DEADLINE_EXPIRED
|
|
342
|
+
// Row 13 (events.publish): standard only (Host policy errors stay transport-owned)
|
|
343
|
+
//
|
|
344
|
+
// Standard errors are always allowed on every row. Application error
|
|
345
|
+
// codes NOT in the per-row allowlist → T-H.
|
|
346
|
+
const EMPTY_ERROR_SET = new Set();
|
|
347
|
+
const HANDSHAKE_ERROR_SET = new Set([HANDSHAKE_REJECTED_CODE]);
|
|
348
|
+
const MESSAGING_ERROR_SET = new Set([DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE]);
|
|
349
|
+
const SNAPSHOT_ERROR_SET = new Set([DOMAIN_ERROR_CODE, DEADLINE_EXPIRED_CODE, SNAPSHOT_UNAVAILABLE_CODE]);
|
|
350
|
+
const DELIVERY_ERROR_SET = new Set([DELIVERY_REJECTED_CODE]);
|
|
351
|
+
const DEADLINE_ONLY_SET = new Set([DEADLINE_EXPIRED_CODE]);
|
|
352
|
+
const METHOD_APPLICATION_ERROR_ALLOW = {
|
|
353
|
+
'broker.hello': HANDSHAKE_ERROR_SET,
|
|
354
|
+
'broker.ready': HANDSHAKE_ERROR_SET,
|
|
355
|
+
'messaging.send': MESSAGING_ERROR_SET,
|
|
356
|
+
'messaging.appendElements': MESSAGING_ERROR_SET,
|
|
357
|
+
'messaging.subscribe': MESSAGING_ERROR_SET,
|
|
358
|
+
'messaging.read': MESSAGING_ERROR_SET,
|
|
359
|
+
'messaging.ack': MESSAGING_ERROR_SET,
|
|
360
|
+
'messaging.snapshot': SNAPSHOT_ERROR_SET,
|
|
361
|
+
'host.messaging.deliver': DELIVERY_ERROR_SET,
|
|
362
|
+
'host.grants.changed': EMPTY_ERROR_SET, // notification-only
|
|
363
|
+
'host.lifecycle.ping': EMPTY_ERROR_SET, // standard only
|
|
364
|
+
'host.lifecycle.drain': DEADLINE_ONLY_SET,
|
|
365
|
+
'events.publish': EMPTY_ERROR_SET,
|
|
366
|
+
};
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Response candidate sub-classifier (T-H / T-L)
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
function classifyResponseCandidate(value, inFlight) {
|
|
371
|
+
const hasResult = 'result' in value;
|
|
372
|
+
const hasError = 'error' in value;
|
|
373
|
+
// ── Closed envelope structure ──────────────────────────────────────
|
|
374
|
+
if (value.jsonrpc !== '2.0')
|
|
375
|
+
return close('T-H');
|
|
376
|
+
// Mutual exclusivity: exactly one of result/error
|
|
377
|
+
if (hasResult && hasError)
|
|
378
|
+
return close('T-H');
|
|
379
|
+
// Closed outer keys: no additional members
|
|
380
|
+
const allowedKeys = hasResult ? RESPONSE_SUCCESS_KEYS : RESPONSE_ERROR_KEYS;
|
|
381
|
+
for (const key of Object.keys(value)) {
|
|
382
|
+
if (!allowedKeys.has(key))
|
|
383
|
+
return close('T-H');
|
|
384
|
+
}
|
|
385
|
+
// ── id validation ──────────────────────────────────────────────────
|
|
386
|
+
if (!('id' in value))
|
|
387
|
+
return close('T-H');
|
|
388
|
+
const id = value.id;
|
|
389
|
+
if (typeof id !== 'string')
|
|
390
|
+
return close('T-H');
|
|
391
|
+
if (validateRequestId(id) === null)
|
|
392
|
+
return close('T-H');
|
|
393
|
+
// ── In-flight correlation ──────────────────────────────────────────
|
|
394
|
+
const inFlightEntry = inFlight.get(id);
|
|
395
|
+
if (inFlightEntry === undefined)
|
|
396
|
+
return close('T-H');
|
|
397
|
+
// ── Error body: closed union validation ────────────────────────────
|
|
398
|
+
if (hasError) {
|
|
399
|
+
const error = value.error;
|
|
400
|
+
if (error === null || typeof error !== 'object' || Array.isArray(error)) {
|
|
401
|
+
return close('T-H');
|
|
402
|
+
}
|
|
403
|
+
const errObj = error;
|
|
404
|
+
// code: number, message: string (structural)
|
|
405
|
+
if (typeof errObj.code !== 'number')
|
|
406
|
+
return close('T-H');
|
|
407
|
+
if (typeof errObj.message !== 'string')
|
|
408
|
+
return close('T-H');
|
|
409
|
+
// Error code must be from the closed set
|
|
410
|
+
if (!KNOWN_ERROR_CODES.has(errObj.code))
|
|
411
|
+
return close('T-H');
|
|
412
|
+
// Code → message canonical mapping
|
|
413
|
+
const expectedMessage = ERROR_CODE_TO_MESSAGE[errObj.code];
|
|
414
|
+
if (errObj.message !== expectedMessage)
|
|
415
|
+
return close('T-H');
|
|
416
|
+
// Standard vs application error body structure
|
|
417
|
+
if (APPLICATION_CODES.has(errObj.code)) {
|
|
418
|
+
// Application errors (-32090..-32094) MUST have `data` (object)
|
|
419
|
+
if (!('data' in errObj))
|
|
420
|
+
return close('T-H');
|
|
421
|
+
if (errObj.data === null || typeof errObj.data !== 'object' || Array.isArray(errObj.data)) {
|
|
422
|
+
return close('T-H');
|
|
423
|
+
}
|
|
424
|
+
// Closed keys: {code, message, data} only
|
|
425
|
+
for (const key of Object.keys(errObj)) {
|
|
426
|
+
if (!ERROR_BODY_APPLICATION_KEYS.has(key))
|
|
427
|
+
return close('T-H');
|
|
428
|
+
}
|
|
429
|
+
// Per-arm data schema validation
|
|
430
|
+
const dataCheck = validateApplicationErrorData(errObj.code, errObj.data);
|
|
431
|
+
if (dataCheck !== null)
|
|
432
|
+
return dataCheck;
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
// Standard errors: ParseError (-32700) mandates id: null.
|
|
436
|
+
// We already validated id is a string (not null) upstream.
|
|
437
|
+
// Therefore ParseError with string id is a protocol violation.
|
|
438
|
+
if (NULL_ID_ERROR_CODES.has(errObj.code))
|
|
439
|
+
return close('T-H');
|
|
440
|
+
// Standard errors (-32700..-32603) MUST NOT have `data`
|
|
441
|
+
if ('data' in errObj)
|
|
442
|
+
return close('T-H');
|
|
443
|
+
// Closed keys: {code, message} only
|
|
444
|
+
for (const key of Object.keys(errObj)) {
|
|
445
|
+
if (!ERROR_BODY_STANDARD_KEYS.has(key))
|
|
446
|
+
return close('T-H');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// ── Per-method error code restriction ────────────────────────────
|
|
450
|
+
// Application errors are only valid on methods whose frozen per-row
|
|
451
|
+
// error set includes them. Standard errors are always allowed.
|
|
452
|
+
// The complete map covers all 13 rows — no fallback needed.
|
|
453
|
+
if (APPLICATION_CODES.has(errObj.code)) {
|
|
454
|
+
if (!METHOD_APPLICATION_ERROR_ALLOW[inFlightEntry.method].has(errObj.code)) {
|
|
455
|
+
return close('T-H');
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return accept('T-L');
|
|
459
|
+
}
|
|
460
|
+
// ── Success result: method-specific shape validation ───────────────
|
|
461
|
+
const resultCheck = validateResponseResult(value.result, inFlightEntry);
|
|
462
|
+
if (resultCheck !== null)
|
|
463
|
+
return resultCheck;
|
|
464
|
+
return accept('T-L');
|
|
465
|
+
}
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// Application error data schema validation (per-arm)
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
/**
|
|
470
|
+
* Validate the `data` field of an application error against the
|
|
471
|
+
* per-arm closed schema (additionalProperties: false).
|
|
472
|
+
*
|
|
473
|
+
* 5 arms: HandshakeRejected, DeliveryRejected, DomainError,
|
|
474
|
+
* DeadlineExpired, SnapshotUnavailable.
|
|
475
|
+
*
|
|
476
|
+
* Contract seam: DomainError.data.code (MessagingErrorCode) has no
|
|
477
|
+
* runtime enum in the contract public surface — only the TypeScript
|
|
478
|
+
* type union exists. We validate structure (key + string type) but
|
|
479
|
+
* skip enum validation to avoid a second truth source (P15).
|
|
480
|
+
* See Sol R3 F1 → Fable escalation.
|
|
481
|
+
*/
|
|
482
|
+
function validateApplicationErrorData(code, data) {
|
|
483
|
+
switch (code) {
|
|
484
|
+
case HANDSHAKE_REJECTED_CODE: {
|
|
485
|
+
// data: { reason: HandshakeRejectReason } — closed
|
|
486
|
+
for (const key of Object.keys(data)) {
|
|
487
|
+
if (!REASON_DATA_KEYS.has(key))
|
|
488
|
+
return close('T-H');
|
|
489
|
+
}
|
|
490
|
+
if (typeof data.reason !== 'string')
|
|
491
|
+
return close('T-H');
|
|
492
|
+
if (!HANDSHAKE_REASONS.has(data.reason))
|
|
493
|
+
return close('T-H');
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
case DELIVERY_REJECTED_CODE: {
|
|
497
|
+
// data: { reason: DeliveryRejectReason } — closed
|
|
498
|
+
for (const key of Object.keys(data)) {
|
|
499
|
+
if (!REASON_DATA_KEYS.has(key))
|
|
500
|
+
return close('T-H');
|
|
501
|
+
}
|
|
502
|
+
if (typeof data.reason !== 'string')
|
|
503
|
+
return close('T-H');
|
|
504
|
+
if (!DELIVERY_REASONS.has(data.reason))
|
|
505
|
+
return close('T-H');
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
case DOMAIN_ERROR_CODE: {
|
|
509
|
+
// data: { code: MessagingErrorCode } — closed keys + enum
|
|
510
|
+
// MESSAGING_ERROR_CODE_SET from contract-mirror.ts (drift-tested
|
|
511
|
+
// against messaging.schema.json enum, Fable ruling on R3 seam).
|
|
512
|
+
for (const key of Object.keys(data)) {
|
|
513
|
+
if (!CODE_DATA_KEYS.has(key))
|
|
514
|
+
return close('T-H');
|
|
515
|
+
}
|
|
516
|
+
if (typeof data.code !== 'string')
|
|
517
|
+
return close('T-H');
|
|
518
|
+
if (!MESSAGING_ERROR_CODE_SET.has(data.code))
|
|
519
|
+
return close('T-H');
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
case DEADLINE_EXPIRED_CODE: {
|
|
523
|
+
// data: Record<string, never> — must be empty object
|
|
524
|
+
if (Object.keys(data).length !== 0)
|
|
525
|
+
return close('T-H');
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
case SNAPSHOT_UNAVAILABLE_CODE: {
|
|
529
|
+
// data: { reason: SnapshotUnavailableReason } — closed
|
|
530
|
+
for (const key of Object.keys(data)) {
|
|
531
|
+
if (!REASON_DATA_KEYS.has(key))
|
|
532
|
+
return close('T-H');
|
|
533
|
+
}
|
|
534
|
+
if (typeof data.reason !== 'string')
|
|
535
|
+
return close('T-H');
|
|
536
|
+
if (!SNAPSHOT_REASONS.has(data.reason))
|
|
537
|
+
return close('T-H');
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
default:
|
|
541
|
+
// Unknown application code — should be unreachable since
|
|
542
|
+
// APPLICATION_CODES was already checked. Defense-in-depth.
|
|
543
|
+
return close('T-H');
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// Response result shape validation (per-method)
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
/**
|
|
550
|
+
* Validate the `result` value of a success response against the
|
|
551
|
+
* correlated in-flight method's expected result shape.
|
|
552
|
+
*
|
|
553
|
+
* CLOSED rows receive full per-method result validation
|
|
554
|
+
* (additionalProperties: false). Any future RESERVED row fails closed.
|
|
555
|
+
*
|
|
556
|
+
* Returns null if valid; DispatchResult (T-H) if invalid.
|
|
557
|
+
*/
|
|
558
|
+
function validateResponseResult(result, entry) {
|
|
559
|
+
const method = entry.method;
|
|
560
|
+
const row = WIRE_METHOD_REGISTRY[method];
|
|
561
|
+
// Defense-in-depth for a future RESERVED row without an executable result.
|
|
562
|
+
if (row.leafClosure !== 'CLOSED') {
|
|
563
|
+
return close('T-H');
|
|
564
|
+
}
|
|
565
|
+
if (isMessagingRowMethod(method)) {
|
|
566
|
+
const validated = validateMessagingRowResult(method, result);
|
|
567
|
+
if (!validated.valid)
|
|
568
|
+
return close('T-H');
|
|
569
|
+
if (method === 'messaging.read') {
|
|
570
|
+
const readLimit = entry.requestSnapshot?.readLimit;
|
|
571
|
+
if (readLimit === undefined ||
|
|
572
|
+
!validateMessagingRowInput('messaging.read', {
|
|
573
|
+
subscriptionId: 'request-snapshot',
|
|
574
|
+
limit: readLimit,
|
|
575
|
+
}).valid ||
|
|
576
|
+
validated.value.events.length > readLimit) {
|
|
577
|
+
return close('T-H');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (method === 'messaging.snapshot') {
|
|
581
|
+
const snapshotMaxItems = entry.requestSnapshot?.snapshotMaxItems;
|
|
582
|
+
if (snapshotMaxItems === undefined ||
|
|
583
|
+
!validateMessagingRowInput('messaging.snapshot', {
|
|
584
|
+
subscriptionId: 'request-snapshot',
|
|
585
|
+
maxItems: snapshotMaxItems,
|
|
586
|
+
}).valid ||
|
|
587
|
+
validated.value.items.length > snapshotMaxItems) {
|
|
588
|
+
return close('T-H');
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (method === 'messaging.appendElements') {
|
|
592
|
+
const appendElementIds = entry.requestSnapshot?.appendElementIds;
|
|
593
|
+
if (appendElementIds === undefined)
|
|
594
|
+
return close('T-H');
|
|
595
|
+
const appliedIds = validated.value.appliedElementIds;
|
|
596
|
+
for (const id of appliedIds) {
|
|
597
|
+
if (!appendElementIds.includes(id))
|
|
598
|
+
return close('T-H');
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (method === 'host.messaging.deliver') {
|
|
602
|
+
const deliveryId = entry.requestSnapshot?.deliveryId;
|
|
603
|
+
if (deliveryId === undefined ||
|
|
604
|
+
!validateMessagingRowResult('host.messaging.deliver', { deliveryId }).valid ||
|
|
605
|
+
validated.value.deliveryId !== deliveryId) {
|
|
606
|
+
return close('T-H');
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
switch (method) {
|
|
612
|
+
case 'host.lifecycle.ping': {
|
|
613
|
+
// PingResult: {nonce: string} — additionalProperties: false
|
|
614
|
+
if (result === null || typeof result !== 'object' || Array.isArray(result)) {
|
|
615
|
+
return close('T-H');
|
|
616
|
+
}
|
|
617
|
+
const obj = result;
|
|
618
|
+
// Closed keys: {nonce} only
|
|
619
|
+
for (const key of Object.keys(obj)) {
|
|
620
|
+
if (!PING_RESULT_KEYS.has(key))
|
|
621
|
+
return close('T-H');
|
|
622
|
+
}
|
|
623
|
+
if (typeof obj.nonce !== 'string')
|
|
624
|
+
return close('T-H');
|
|
625
|
+
// Nonce bounds
|
|
626
|
+
const cpLen = [...obj.nonce].length;
|
|
627
|
+
if (cpLen < PING_NONCE_MIN_LENGTH || cpLen > PING_NONCE_MAX_LENGTH) {
|
|
628
|
+
return close('T-H');
|
|
629
|
+
}
|
|
630
|
+
// Cross-frame oracle: nonce byte-equality (REQUIRED for ping).
|
|
631
|
+
// Ping's nonce echo is the fundamental liveness proof — accepting
|
|
632
|
+
// a response without verifying the oracle defeats the purpose.
|
|
633
|
+
// Missing snapshot is a caller bug; fail-closed, not fail-open.
|
|
634
|
+
if (entry.requestSnapshot?.nonce === undefined)
|
|
635
|
+
return close('T-H');
|
|
636
|
+
if (obj.nonce !== entry.requestSnapshot.nonce)
|
|
637
|
+
return close('T-H');
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
case 'broker.hello': {
|
|
641
|
+
if (!validateSessionBinding(result))
|
|
642
|
+
return close('T-H');
|
|
643
|
+
// SessionBinding is Host-authoritative only for H5–H9. Its four
|
|
644
|
+
// candidate fields are cross-frame echoes, so accepting a structurally
|
|
645
|
+
// valid but different binding would settle the wrong in-flight hello.
|
|
646
|
+
const candidateHello = entry.requestSnapshot?.candidateHello;
|
|
647
|
+
if (candidateHello === undefined)
|
|
648
|
+
return close('T-H');
|
|
649
|
+
if (result.pluginId !== candidateHello.pluginId ||
|
|
650
|
+
result.packageDigest !== candidateHello.packageDigest ||
|
|
651
|
+
result.contractVersion !== candidateHello.contractVersion ||
|
|
652
|
+
result.wireVersion !== candidateHello.wireVersion) {
|
|
653
|
+
return close('T-H');
|
|
654
|
+
}
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
case 'broker.ready': {
|
|
658
|
+
if (result !== null)
|
|
659
|
+
return close('T-H');
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
case 'host.lifecycle.drain': {
|
|
663
|
+
// DrainResult: null
|
|
664
|
+
if (result !== null)
|
|
665
|
+
return close('T-H');
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
case 'events.publish': {
|
|
669
|
+
return validateEventsPublishResult(result).valid ? null : close('T-H');
|
|
670
|
+
}
|
|
671
|
+
case 'host.grants.changed': {
|
|
672
|
+
// Notification-only — should never be in in-flight.
|
|
673
|
+
// Direction gate prevents this; defense-in-depth.
|
|
674
|
+
return close('T-H');
|
|
675
|
+
}
|
|
676
|
+
default: {
|
|
677
|
+
// Unreachable for CLOSED rows (all covered above).
|
|
678
|
+
// Defense-in-depth: unknown method in in-flight → T-H.
|
|
679
|
+
return close('T-H');
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
// Notification sub-classifier (T-J / T-K)
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
function classifyNotification(value) {
|
|
687
|
+
const method = value.method;
|
|
688
|
+
// Closed outer keys: {jsonrpc, method, params} only
|
|
689
|
+
for (const key of Object.keys(value)) {
|
|
690
|
+
if (!NOTIFICATION_ALLOWED_KEYS.has(key))
|
|
691
|
+
return close('T-K');
|
|
692
|
+
}
|
|
693
|
+
// Missing params → T-K
|
|
694
|
+
if (!('params' in value))
|
|
695
|
+
return close('T-K');
|
|
696
|
+
// Only row 10 (host.grants.changed) is a legal notification in v0
|
|
697
|
+
const isLegalNotification = NOTIFICATION_METHODS.includes(method);
|
|
698
|
+
if (!isLegalNotification)
|
|
699
|
+
return close('T-K');
|
|
700
|
+
// Validate params structure
|
|
701
|
+
const params = value.params;
|
|
702
|
+
if (params === null || typeof params !== 'object' || Array.isArray(params)) {
|
|
703
|
+
return close('T-K');
|
|
704
|
+
}
|
|
705
|
+
const paramsObj = params;
|
|
706
|
+
// Closed params keys: {meta, input} only
|
|
707
|
+
for (const key of Object.keys(paramsObj)) {
|
|
708
|
+
if (!PARAMS_ALLOWED_KEYS.has(key))
|
|
709
|
+
return close('T-K');
|
|
710
|
+
}
|
|
711
|
+
// meta.deadlineUnixMs
|
|
712
|
+
if (!('meta' in paramsObj))
|
|
713
|
+
return close('T-K');
|
|
714
|
+
const meta = paramsObj.meta;
|
|
715
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
|
|
716
|
+
return close('T-K');
|
|
717
|
+
}
|
|
718
|
+
const metaObj = meta;
|
|
719
|
+
// Closed meta keys: {deadlineUnixMs} only
|
|
720
|
+
for (const key of Object.keys(metaObj)) {
|
|
721
|
+
if (!META_ALLOWED_KEYS.has(key))
|
|
722
|
+
return close('T-K');
|
|
723
|
+
}
|
|
724
|
+
const notifDeadline = metaObj.deadlineUnixMs;
|
|
725
|
+
if (typeof notifDeadline !== 'number' || !isWireUInt53(notifDeadline))
|
|
726
|
+
return close('T-K');
|
|
727
|
+
if (notifDeadline === 0)
|
|
728
|
+
return close('T-K');
|
|
729
|
+
// input validation for row 10 (grants.changed)
|
|
730
|
+
if (!('input' in paramsObj))
|
|
731
|
+
return close('T-K');
|
|
732
|
+
const input = paramsObj.input;
|
|
733
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
734
|
+
return close('T-K');
|
|
735
|
+
}
|
|
736
|
+
const inputObj = input;
|
|
737
|
+
// Closed input keys: {grantRevision, effectiveGrants} only
|
|
738
|
+
for (const key of Object.keys(inputObj)) {
|
|
739
|
+
if (!GRANTS_CHANGED_INPUT_KEYS.has(key))
|
|
740
|
+
return close('T-K');
|
|
741
|
+
}
|
|
742
|
+
// grantRevision must be WireUInt53 (≥0)
|
|
743
|
+
const grantRev = inputObj.grantRevision;
|
|
744
|
+
if (typeof grantRev !== 'number' || !isWireUInt53(grantRev))
|
|
745
|
+
return close('T-K');
|
|
746
|
+
// effectiveGrants must pass authorization boundary validation
|
|
747
|
+
if (!Array.isArray(inputObj.effectiveGrants))
|
|
748
|
+
return close('T-K');
|
|
749
|
+
if (!validateEffectiveGrants(inputObj.effectiveGrants))
|
|
750
|
+
return close('T-K');
|
|
751
|
+
return accept('T-J');
|
|
752
|
+
}
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// Request sub-classifier (T-E / T-F / T-G / T-I / accept)
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
function classifyRequest(value, id, method, inFlight) {
|
|
757
|
+
// Closed outer keys: {jsonrpc, id, method, params} only
|
|
758
|
+
for (const key of Object.keys(value)) {
|
|
759
|
+
if (!REQUEST_ALLOWED_KEYS.has(key))
|
|
760
|
+
return respondInvalidRequestId(id);
|
|
761
|
+
}
|
|
762
|
+
// Missing params → T-F InvalidRequest
|
|
763
|
+
if (!('params' in value))
|
|
764
|
+
return respondInvalidRequestId(id);
|
|
765
|
+
// params must be an object
|
|
766
|
+
const params = value.params;
|
|
767
|
+
if (params === null || typeof params !== 'object') {
|
|
768
|
+
return respondInvalidParams(id);
|
|
769
|
+
}
|
|
770
|
+
if (Array.isArray(params))
|
|
771
|
+
return respondInvalidParams(id);
|
|
772
|
+
// Method gate: unknown method → T-F MethodNotFound
|
|
773
|
+
if (!isWireMethod(method))
|
|
774
|
+
return respondMethodNotFound(id);
|
|
775
|
+
// Direction gate: notification-only methods must not appear as requests
|
|
776
|
+
const wireMethod = method;
|
|
777
|
+
const row = WIRE_METHOD_REGISTRY[wireMethod];
|
|
778
|
+
if (row.isNotification)
|
|
779
|
+
return respondInvalidRequestId(id);
|
|
780
|
+
// In-flight collision → T-I
|
|
781
|
+
if (inFlight.has(id))
|
|
782
|
+
return close('T-I');
|
|
783
|
+
const paramsObj = params;
|
|
784
|
+
// Closed params keys: {meta, input} only
|
|
785
|
+
for (const key of Object.keys(paramsObj)) {
|
|
786
|
+
if (!PARAMS_ALLOWED_KEYS.has(key))
|
|
787
|
+
return respondInvalidParams(id);
|
|
788
|
+
}
|
|
789
|
+
// Validate params.meta structure
|
|
790
|
+
if (!('meta' in paramsObj))
|
|
791
|
+
return respondInvalidParams(id);
|
|
792
|
+
const meta = paramsObj.meta;
|
|
793
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
|
|
794
|
+
return respondInvalidParams(id);
|
|
795
|
+
}
|
|
796
|
+
const metaObj = meta;
|
|
797
|
+
// Closed meta keys: {deadlineUnixMs} only
|
|
798
|
+
for (const key of Object.keys(metaObj)) {
|
|
799
|
+
if (!META_ALLOWED_KEYS.has(key))
|
|
800
|
+
return respondInvalidParamsValue(id);
|
|
801
|
+
}
|
|
802
|
+
// deadlineUnixMs must be positive WireUInt53
|
|
803
|
+
const reqDeadline = metaObj.deadlineUnixMs;
|
|
804
|
+
if (typeof reqDeadline !== 'number' || !isWireUInt53(reqDeadline))
|
|
805
|
+
return respondInvalidParamsValue(id);
|
|
806
|
+
if (reqDeadline === 0)
|
|
807
|
+
return respondInvalidParamsValue(id);
|
|
808
|
+
// Validate params.input exists and is an object
|
|
809
|
+
if (!('input' in paramsObj))
|
|
810
|
+
return respondInvalidParams(id);
|
|
811
|
+
const input = paramsObj.input;
|
|
812
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
813
|
+
return respondInvalidParams(id);
|
|
814
|
+
}
|
|
815
|
+
// Row-specific value validation
|
|
816
|
+
if (row.leafClosure === 'CLOSED') {
|
|
817
|
+
const valueResult = validateClosedRowInput(wireMethod, input, id);
|
|
818
|
+
if (valueResult !== null)
|
|
819
|
+
return valueResult;
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
// Fail closed if a future registry revision introduces a RESERVED row:
|
|
823
|
+
// its input type is `never`, so no legal params value exists.
|
|
824
|
+
return respondInvalidParamsValue(id);
|
|
825
|
+
}
|
|
826
|
+
// Every ready request row is legal at the contract boundary. The standalone
|
|
827
|
+
// shell decides whether a legal request is Host-bound or locally executable;
|
|
828
|
+
// classification must not relabel either family as a rejection or as an
|
|
829
|
+
// unclassified accept.
|
|
830
|
+
if (row.ready) {
|
|
831
|
+
return accept('T-M');
|
|
832
|
+
}
|
|
833
|
+
// Defense-in-depth for a future closed-but-unready plugin-to-host row.
|
|
834
|
+
//
|
|
835
|
+
// Positioned after all envelope/value checks so that:
|
|
836
|
+
// - In-flight collision (T-I) takes precedence (per contract fixtures)
|
|
837
|
+
// - Future RESERVED-row rejection (T-G) takes precedence
|
|
838
|
+
// - Only CLOSED, unready plugin-to-host rows can reach here
|
|
839
|
+
if (row.direction === 'plugin-to-host')
|
|
840
|
+
return respondMethodNotFound(id);
|
|
841
|
+
// All checks passed — valid host-to-plugin CLOSED-row request for dispatch
|
|
842
|
+
return { disposition: null, outcome: 'accept' };
|
|
843
|
+
}
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
// CLOSED row input validation (T-G detection)
|
|
846
|
+
// ---------------------------------------------------------------------------
|
|
847
|
+
function validateClosedRowInput(method, input, id) {
|
|
848
|
+
if (isMessagingRowMethod(method)) {
|
|
849
|
+
return validateMessagingRowInput(method, input).valid
|
|
850
|
+
? null
|
|
851
|
+
: respondInvalidParamsValue(id);
|
|
852
|
+
}
|
|
853
|
+
switch (method) {
|
|
854
|
+
case 'broker.hello':
|
|
855
|
+
if (hasHandshakeAuthorityInjection(input))
|
|
856
|
+
return respondHandshakeAuthorityViolation(id);
|
|
857
|
+
return validateCandidateHello(input) ? null : respondInvalidParamsValue(id);
|
|
858
|
+
case 'broker.ready':
|
|
859
|
+
if (hasHandshakeAuthorityInjection(input))
|
|
860
|
+
return respondHandshakeAuthorityViolation(id);
|
|
861
|
+
return validateBrokerReadyParams(input) ? null : respondInvalidParamsValue(id);
|
|
862
|
+
case 'events.publish':
|
|
863
|
+
return validateEventsPublishInput(input).valid
|
|
864
|
+
? null
|
|
865
|
+
: respondInvalidParamsValue(id);
|
|
866
|
+
case 'host.lifecycle.ping': {
|
|
867
|
+
// Closed input keys: {nonce} only
|
|
868
|
+
for (const key of Object.keys(input)) {
|
|
869
|
+
if (!PING_INPUT_KEYS.has(key))
|
|
870
|
+
return respondInvalidParamsValue(id);
|
|
871
|
+
}
|
|
872
|
+
// nonce must be string, 1..512 code points
|
|
873
|
+
if (typeof input.nonce !== 'string')
|
|
874
|
+
return respondInvalidParamsValue(id);
|
|
875
|
+
const cpLen = [...input.nonce].length;
|
|
876
|
+
if (cpLen < PING_NONCE_MIN_LENGTH || cpLen > PING_NONCE_MAX_LENGTH) {
|
|
877
|
+
return respondInvalidParamsValue(id);
|
|
878
|
+
}
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
case 'host.lifecycle.drain': {
|
|
882
|
+
// Closed input keys: {deadlineUnixMs} only
|
|
883
|
+
for (const key of Object.keys(input)) {
|
|
884
|
+
if (!DRAIN_INPUT_KEYS.has(key))
|
|
885
|
+
return respondInvalidParamsValue(id);
|
|
886
|
+
}
|
|
887
|
+
// input.deadlineUnixMs must be positive WireUInt53
|
|
888
|
+
const drainDeadline = input.deadlineUnixMs;
|
|
889
|
+
if (typeof drainDeadline !== 'number' || !isWireUInt53(drainDeadline))
|
|
890
|
+
return respondInvalidParamsValue(id);
|
|
891
|
+
if (drainDeadline === 0)
|
|
892
|
+
return respondInvalidParamsValue(id);
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
// host.grants.changed (row 10) is notification-only — direction gate
|
|
896
|
+
// in classifyRequest rejects it before reaching this function.
|
|
897
|
+
default:
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
// ---------------------------------------------------------------------------
|
|
902
|
+
// Main classifier entry point
|
|
903
|
+
// ---------------------------------------------------------------------------
|
|
904
|
+
/**
|
|
905
|
+
* Classify a decoded NDJSON frame into a disposition class.
|
|
906
|
+
*
|
|
907
|
+
* Covers T-C through T-M of the disposition table. T-A and T-B
|
|
908
|
+
* are handled by the NDJSON frame decoder layer.
|
|
909
|
+
*
|
|
910
|
+
* @param frame Decoded frame with raw bytes and parsed value.
|
|
911
|
+
* @param inFlight Map of in-flight request IDs to their entry metadata.
|
|
912
|
+
* @returns The disposition result with class, outcome, and optional error response.
|
|
913
|
+
*/
|
|
914
|
+
export function classifyFrame(frame, inFlight) {
|
|
915
|
+
const { raw, value } = frame;
|
|
916
|
+
// ── T-C: canonicality check ──────────────────────────────────────────
|
|
917
|
+
// The T-C predicate covers: whitespace, duplicate keys, non-scalar
|
|
918
|
+
// strings, non-canonical numbers, and BOM-prefixed frames.
|
|
919
|
+
// Byte-equality catches whitespace, duplicate keys, and most non-
|
|
920
|
+
// canonical numbers. Two supplementary measures close the remaining
|
|
921
|
+
// gaps:
|
|
922
|
+
// 1. ignoreBOM:true — keeps BOM visible so it fails byte-equality.
|
|
923
|
+
// 2. containsNonScalarString — lone surrogates roundtrip thru stringify.
|
|
924
|
+
// For protocol-defined WireUInt53 positions (meta.deadlineUnixMs, row-
|
|
925
|
+
// specific input/result leaves including M0-C messaging fields),
|
|
926
|
+
// hasNonCanonicalUInt53Token validates String(n) against the canonical
|
|
927
|
+
// grammar (0|[1-9][0-9]{0,15}) — this catches fractions (1.5),
|
|
928
|
+
// negatives (-1), and V8-canonical exponent form (1e+21) at protocol
|
|
929
|
+
// positions, without rejecting valid numbers in open payloads
|
|
930
|
+
// (e.g. MessageElement.payload).
|
|
931
|
+
//
|
|
932
|
+
// Guard: JSON.stringify and the deep-traversal helpers use recursive
|
|
933
|
+
// descent. A canonical frame nested thousands of levels deep passes
|
|
934
|
+
// V8's iterative JSON.parse but overflows the call stack on stringify.
|
|
935
|
+
// The try-catch ensures classifyFrame never throws — stack overflow
|
|
936
|
+
// is mapped to T-C (close, no response).
|
|
937
|
+
try {
|
|
938
|
+
const rawStr = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(raw);
|
|
939
|
+
const canonical = JSON.stringify(value);
|
|
940
|
+
if (rawStr !== canonical)
|
|
941
|
+
return close('T-C');
|
|
942
|
+
if (containsNonScalarString(value))
|
|
943
|
+
return close('T-C');
|
|
944
|
+
// P1-1: WireUInt53 raw-token grammar — after byte-equality, the raw
|
|
945
|
+
// token at each WireUInt53 position is String(parsedValue). Tokens
|
|
946
|
+
// like "-1", "1.5", or "1e+21" violate 0|[1-9][0-9]{0,15} → T-C.
|
|
947
|
+
if (hasNonCanonicalUInt53Token(value, inFlight))
|
|
948
|
+
return close('T-C');
|
|
949
|
+
}
|
|
950
|
+
catch {
|
|
951
|
+
// Stack overflow from deep nesting, or other canonicality edge case.
|
|
952
|
+
return close('T-C');
|
|
953
|
+
}
|
|
954
|
+
// ── Response candidate detection ─────────────────────────────────────
|
|
955
|
+
const hasMethod = 'method' in value;
|
|
956
|
+
const hasResult = 'result' in value;
|
|
957
|
+
const hasError = 'error' in value;
|
|
958
|
+
if (!hasMethod && (hasResult || hasError)) {
|
|
959
|
+
return classifyResponseCandidate(value, inFlight);
|
|
960
|
+
}
|
|
961
|
+
// ── Structural validity ──────────────────────────────────────────────
|
|
962
|
+
if (value.jsonrpc !== '2.0' || typeof value.method !== 'string') {
|
|
963
|
+
if ('id' in value) {
|
|
964
|
+
const id = validateRequestId(value.id);
|
|
965
|
+
if (id !== null) {
|
|
966
|
+
return respondInvalidRequestId(id);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return respondInvalidRequestNull();
|
|
970
|
+
}
|
|
971
|
+
const method = value.method;
|
|
972
|
+
// ── Notification vs Request fork ─────────────────────────────────────
|
|
973
|
+
if (!('id' in value)) {
|
|
974
|
+
return classifyNotification(value);
|
|
975
|
+
}
|
|
976
|
+
// ── T-E: profile-invalid id ──────────────────────────────────────────
|
|
977
|
+
const id = validateRequestId(value.id);
|
|
978
|
+
if (id === null)
|
|
979
|
+
return close('T-E');
|
|
980
|
+
// ── Request path ─────────────────────────────────────────────────────
|
|
981
|
+
return classifyRequest(value, id, method, inFlight);
|
|
982
|
+
}
|
|
983
|
+
//# sourceMappingURL=wire-dispatch.js.map
|