borgmcp-shared 0.2.2
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/CONTRIBUTING.md +40 -0
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/README.md +159 -0
- package/SECURITY.md +25 -0
- package/dist/conformance/adapter.d.ts +112 -0
- package/dist/conformance/adapter.d.ts.map +1 -0
- package/dist/conformance/adapter.js +417 -0
- package/dist/conformance/adapter.js.map +1 -0
- package/dist/conformance/index.d.ts +15 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +49 -0
- package/dist/conformance/index.js.map +1 -0
- package/dist/domain/index.d.ts +5 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/domain/index.js +4 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/drone-address.d.ts +3 -0
- package/dist/drone-address.d.ts.map +1 -0
- package/dist/drone-address.js +7 -0
- package/dist/drone-address.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/log-stream-hwm.d.ts +6 -0
- package/dist/log-stream-hwm.d.ts.map +1 -0
- package/dist/log-stream-hwm.js +12 -0
- package/dist/log-stream-hwm.js.map +1 -0
- package/dist/protocol/contract.d.ts +127 -0
- package/dist/protocol/contract.d.ts.map +1 -0
- package/dist/protocol/contract.js +434 -0
- package/dist/protocol/contract.js.map +1 -0
- package/dist/protocol/coordination.d.ts +38 -0
- package/dist/protocol/coordination.d.ts.map +1 -0
- package/dist/protocol/coordination.js +176 -0
- package/dist/protocol/coordination.js.map +1 -0
- package/dist/protocol/errors.d.ts +33 -0
- package/dist/protocol/errors.d.ts.map +1 -0
- package/dist/protocol/errors.js +26 -0
- package/dist/protocol/errors.js.map +1 -0
- package/dist/protocol/index.d.ts +8 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +7 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/protocol/sse.d.ts +44 -0
- package/dist/protocol/sse.d.ts.map +1 -0
- package/dist/protocol/sse.js +246 -0
- package/dist/protocol/sse.js.map +1 -0
- package/dist/protocol/types.d.ts +174 -0
- package/dist/protocol/types.d.ts.map +1 -0
- package/dist/protocol/types.js +2 -0
- package/dist/protocol/types.js.map +1 -0
- package/dist/protocol/version.d.ts +11 -0
- package/dist/protocol/version.d.ts.map +1 -0
- package/dist/protocol/version.js +14 -0
- package/dist/protocol/version.js.map +1 -0
- package/dist/role-section.d.ts +24 -0
- package/dist/role-section.d.ts.map +1 -0
- package/dist/role-section.js +114 -0
- package/dist/role-section.js.map +1 -0
- package/dist/templates.d.ts +44 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/templates.js +771 -0
- package/dist/templates.js.map +1 -0
- package/docs/compatibility.md +38 -0
- package/docs/releasing.md +220 -0
- package/package.json +89 -0
- package/src/conformance/adapter.ts +717 -0
- package/src/conformance/index.ts +68 -0
- package/src/domain/index.ts +9 -0
- package/src/drone-address.ts +25 -0
- package/src/index.ts +4 -0
- package/src/log-stream-hwm.ts +16 -0
- package/src/protocol/contract.ts +608 -0
- package/src/protocol/coordination.ts +249 -0
- package/src/protocol/errors.ts +36 -0
- package/src/protocol/index.ts +7 -0
- package/src/protocol/sse.ts +295 -0
- package/src/protocol/types.ts +201 -0
- package/src/protocol/version.ts +31 -0
- package/src/role-section.ts +270 -0
- package/src/templates.ts +953 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ErrorCode,
|
|
3
|
+
ProtocolContractError,
|
|
4
|
+
compareLogCursor,
|
|
5
|
+
createProtocolEnvelope,
|
|
6
|
+
decodeAppendLogResultEnvelope,
|
|
7
|
+
decodeDecisionResultEnvelope,
|
|
8
|
+
decodeDecisionsResultEnvelope,
|
|
9
|
+
decodeEnrollmentExchangeResponseEnvelope,
|
|
10
|
+
decodeProtocolErrorEnvelope,
|
|
11
|
+
decodeProtocolInfoEnvelope,
|
|
12
|
+
decodeReadLogResultEnvelope,
|
|
13
|
+
decodeSseFrames,
|
|
14
|
+
negotiateProtocol,
|
|
15
|
+
utf8ByteLength,
|
|
16
|
+
type Capability,
|
|
17
|
+
type LogCursor,
|
|
18
|
+
type ProtocolInfo,
|
|
19
|
+
type StreamEvent,
|
|
20
|
+
} from '../protocol/index.js';
|
|
21
|
+
|
|
22
|
+
export interface ConformanceHttpResponse {
|
|
23
|
+
status: number;
|
|
24
|
+
body: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ConformancePrincipal {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ConformanceCube {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ConformanceReplayBarrier {
|
|
36
|
+
/** Resolves after the replay snapshot is read but before live delivery is active. */
|
|
37
|
+
readonly reached: Promise<void>;
|
|
38
|
+
release(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ConformanceStreamResponse extends ConformanceHttpResponse {
|
|
42
|
+
/** Present only for a successful streaming response. Chunks are raw SSE wire text. */
|
|
43
|
+
stream: AsyncIterable<string> | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Privileged test-only controls. Production request handlers must never expose
|
|
48
|
+
* these operations; an adapter implements them through its test fixture layer.
|
|
49
|
+
*/
|
|
50
|
+
export interface ConformanceAdmin {
|
|
51
|
+
reset(): Promise<void>;
|
|
52
|
+
createPrincipal(name: string): Promise<ConformancePrincipal>;
|
|
53
|
+
createCube(name: string): Promise<ConformanceCube>;
|
|
54
|
+
grantCube(principal: ConformancePrincipal, cube: ConformanceCube): Promise<void>;
|
|
55
|
+
issueSingleUseInvitation(principal: ConformancePrincipal): Promise<string>;
|
|
56
|
+
revokePrincipal(principal: ConformancePrincipal): Promise<void>;
|
|
57
|
+
expireCursor(cube: ConformanceCube, cursor: LogCursor): Promise<void>;
|
|
58
|
+
armReplayTransition(): ConformanceReplayBarrier;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Raw, authenticated adapter operations driven entirely by the shared runner. */
|
|
62
|
+
export interface ConformanceOperations {
|
|
63
|
+
health(): Promise<ConformanceHttpResponse>;
|
|
64
|
+
protocol(credential: string | null): Promise<ConformanceHttpResponse>;
|
|
65
|
+
enroll(request: unknown): Promise<ConformanceHttpResponse>;
|
|
66
|
+
append(
|
|
67
|
+
credential: string,
|
|
68
|
+
cube: ConformanceCube,
|
|
69
|
+
request: unknown,
|
|
70
|
+
): Promise<ConformanceHttpResponse>;
|
|
71
|
+
/** Sends an exact UTF-8 JSON body through the adapter's size and parsing boundary. */
|
|
72
|
+
appendRaw(
|
|
73
|
+
credential: string,
|
|
74
|
+
cube: ConformanceCube,
|
|
75
|
+
body: string,
|
|
76
|
+
): Promise<ConformanceHttpResponse>;
|
|
77
|
+
read(
|
|
78
|
+
credential: string,
|
|
79
|
+
cube: ConformanceCube,
|
|
80
|
+
request: unknown,
|
|
81
|
+
): Promise<ConformanceHttpResponse>;
|
|
82
|
+
ack(
|
|
83
|
+
credential: string,
|
|
84
|
+
cube: ConformanceCube,
|
|
85
|
+
request: unknown,
|
|
86
|
+
): Promise<ConformanceHttpResponse>;
|
|
87
|
+
recordDecision(
|
|
88
|
+
credential: string,
|
|
89
|
+
cube: ConformanceCube,
|
|
90
|
+
request: unknown,
|
|
91
|
+
): Promise<ConformanceHttpResponse>;
|
|
92
|
+
listDecisions(
|
|
93
|
+
credential: string,
|
|
94
|
+
cube: ConformanceCube,
|
|
95
|
+
request: unknown,
|
|
96
|
+
): Promise<ConformanceHttpResponse>;
|
|
97
|
+
openStream(
|
|
98
|
+
credential: string,
|
|
99
|
+
cube: ConformanceCube,
|
|
100
|
+
cursor: LogCursor | null,
|
|
101
|
+
): Promise<ConformanceStreamResponse>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ConformanceEnvironment {
|
|
105
|
+
readonly admin: ConformanceAdmin;
|
|
106
|
+
readonly operations: ConformanceOperations;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const ADAPTER_CONFORMANCE_FIXTURES = [
|
|
110
|
+
{ id: 'http.unauthenticated-liveness', area: 'http' },
|
|
111
|
+
{ id: 'protocol.enrollment-auth', area: 'protocol' },
|
|
112
|
+
{ id: 'security.adapter-boundary-injection', area: 'security' },
|
|
113
|
+
{ id: 'security.oversize-request', area: 'security' },
|
|
114
|
+
{ id: 'security.cross-cube-isolation', area: 'security' },
|
|
115
|
+
{ id: 'log.read-cursor-tuple', area: 'cursor' },
|
|
116
|
+
{ id: 'sse.replay-live-transition', area: 'sse' },
|
|
117
|
+
{ id: 'cursor.explicit-expiry', area: 'cursor' },
|
|
118
|
+
{ id: 'acks.idempotent', area: 'acks' },
|
|
119
|
+
{ id: 'claims.durable-noncursor', area: 'claims' },
|
|
120
|
+
{ id: 'decisions.topic-supersession', area: 'decisions' },
|
|
121
|
+
{ id: 'capabilities.unsupported-fails-closed', area: 'capabilities' },
|
|
122
|
+
{ id: 'security.active-stream-revocation', area: 'security' },
|
|
123
|
+
] as const;
|
|
124
|
+
|
|
125
|
+
export type AdapterConformanceFixtureId =
|
|
126
|
+
(typeof ADAPTER_CONFORMANCE_FIXTURES)[number]['id'];
|
|
127
|
+
|
|
128
|
+
export interface AdapterConformanceResult {
|
|
129
|
+
id: AdapterConformanceFixtureId;
|
|
130
|
+
ok: boolean;
|
|
131
|
+
observations: Record<string, unknown>;
|
|
132
|
+
error?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface AdapterConformanceReport {
|
|
136
|
+
ok: boolean;
|
|
137
|
+
results: AdapterConformanceResult[];
|
|
138
|
+
/** Stable, identifier-free observations suitable for cross-adapter comparison. */
|
|
139
|
+
normalizedTranscript: ReadonlyArray<{
|
|
140
|
+
id: AdapterConformanceFixtureId;
|
|
141
|
+
observations: Record<string, unknown>;
|
|
142
|
+
}>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface EquivalentAdapterConformanceReport {
|
|
146
|
+
ok: boolean;
|
|
147
|
+
cloud: AdapterConformanceReport;
|
|
148
|
+
local: AdapterConformanceReport;
|
|
149
|
+
equivalent: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface AdapterConformanceOptions {
|
|
153
|
+
/** Maximum wait for a replay, live delivery, or revocation close. */
|
|
154
|
+
streamDeadlineMs?: number;
|
|
155
|
+
/** Observation window used to prove an idle stream remains pending. */
|
|
156
|
+
pendingProbeMs?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const DEFAULT_STREAM_DEADLINE_MS = 5_000;
|
|
160
|
+
const DEFAULT_PENDING_PROBE_MS = 25;
|
|
161
|
+
|
|
162
|
+
function invariant(condition: unknown, message: string): asserts condition {
|
|
163
|
+
if (!condition) throw new Error(message);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function protocolError(response: ConformanceHttpResponse): ErrorCode | null {
|
|
167
|
+
try {
|
|
168
|
+
return decodeProtocolErrorEnvelope(response.body).error.code;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function delay(milliseconds: number): Promise<void> {
|
|
175
|
+
const setTimeoutValue = (globalThis as unknown as {
|
|
176
|
+
setTimeout?: (callback: () => void, delay: number) => unknown;
|
|
177
|
+
}).setTimeout;
|
|
178
|
+
if (!setTimeoutValue) throw new Error('Conformance runner requires a timer implementation.');
|
|
179
|
+
return new Promise((resolve) => setTimeoutValue(resolve, milliseconds));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function within<T>(
|
|
183
|
+
promise: Promise<T>,
|
|
184
|
+
description: string,
|
|
185
|
+
deadlineMs: number,
|
|
186
|
+
): Promise<T> {
|
|
187
|
+
const timers = globalThis as unknown as {
|
|
188
|
+
setTimeout?: (callback: () => void, delay: number) => unknown;
|
|
189
|
+
clearTimeout?: (timer: unknown) => void;
|
|
190
|
+
};
|
|
191
|
+
if (!timers.setTimeout || !timers.clearTimeout) {
|
|
192
|
+
throw new Error('Conformance runner requires timer cancellation support.');
|
|
193
|
+
}
|
|
194
|
+
return new Promise<T>((resolve, reject) => {
|
|
195
|
+
const timer = timers.setTimeout!(() => {
|
|
196
|
+
reject(new Error(`${description} did not settle within ${deadlineMs}ms.`));
|
|
197
|
+
}, deadlineMs);
|
|
198
|
+
promise.then(
|
|
199
|
+
(value) => {
|
|
200
|
+
timers.clearTimeout!(timer);
|
|
201
|
+
resolve(value);
|
|
202
|
+
},
|
|
203
|
+
(error) => {
|
|
204
|
+
timers.clearTimeout!(timer);
|
|
205
|
+
reject(error);
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function provePending<T>(
|
|
212
|
+
promise: Promise<T>,
|
|
213
|
+
description: string,
|
|
214
|
+
probeMs: number,
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
const settled = await Promise.race([
|
|
217
|
+
promise.then(
|
|
218
|
+
() => true,
|
|
219
|
+
() => true,
|
|
220
|
+
),
|
|
221
|
+
delay(probeMs).then(() => false),
|
|
222
|
+
]);
|
|
223
|
+
invariant(!settled, `${description} settled before new activity.`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
class SseEventReader {
|
|
227
|
+
private readonly iterator: AsyncIterator<string>;
|
|
228
|
+
private buffer = '';
|
|
229
|
+
private ended = false;
|
|
230
|
+
|
|
231
|
+
constructor(stream: AsyncIterable<string>) {
|
|
232
|
+
this.iterator = stream[Symbol.asyncIterator]();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async next(): Promise<StreamEvent> {
|
|
236
|
+
while (true) {
|
|
237
|
+
this.buffer = this.buffer
|
|
238
|
+
.replace(/\r\n/g, '\n')
|
|
239
|
+
.replace(this.ended ? /\r/g : /\r(?!$)/g, '\n');
|
|
240
|
+
const boundary = this.buffer.search(/\n\n+/);
|
|
241
|
+
if (boundary >= 0) {
|
|
242
|
+
const match = this.buffer.slice(boundary).match(/^\n\n+/);
|
|
243
|
+
invariant(match, 'Internal SSE frame boundary error.');
|
|
244
|
+
const frame = this.buffer.slice(0, boundary);
|
|
245
|
+
this.buffer = this.buffer.slice(boundary + match[0].length);
|
|
246
|
+
if (frame.trim() === '') continue;
|
|
247
|
+
const events = decodeSseFrames(`${frame}\n\n`);
|
|
248
|
+
invariant(events.length === 1, 'Expected exactly one SSE event per frame.');
|
|
249
|
+
return events[0];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (this.ended) {
|
|
253
|
+
if (this.buffer.trim() !== '') throw new Error('SSE stream ended with an incomplete frame.');
|
|
254
|
+
throw new Error('SSE stream ended while an event was expected.');
|
|
255
|
+
}
|
|
256
|
+
const chunk = await this.iterator.next();
|
|
257
|
+
if (chunk.done) {
|
|
258
|
+
this.ended = true;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
invariant(typeof chunk.value === 'string', 'SSE stream yielded a non-string chunk.');
|
|
262
|
+
this.buffer += chunk.value;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async close(): Promise<void> {
|
|
267
|
+
if (this.iterator.return) await this.iterator.return();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function expectStatus(response: ConformanceHttpResponse, status: number, operation: string): void {
|
|
272
|
+
invariant(
|
|
273
|
+
response.status === status,
|
|
274
|
+
`${operation} returned HTTP ${response.status}; expected ${status}.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function expectError(
|
|
279
|
+
response: ConformanceHttpResponse,
|
|
280
|
+
status: number,
|
|
281
|
+
code: ErrorCode,
|
|
282
|
+
operation: string,
|
|
283
|
+
): void {
|
|
284
|
+
expectStatus(response, status, operation);
|
|
285
|
+
invariant(protocolError(response) === code, `${operation} did not return ${code}.`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function logEvent(event: StreamEvent, description: string): Extract<StreamEvent, { type: 'log' }> {
|
|
289
|
+
invariant(event.type === 'log', `${description} produced ${event.type}, not a log event.`);
|
|
290
|
+
return event;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function same(left: unknown, right: unknown): boolean {
|
|
294
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function runAdapterConformance(
|
|
298
|
+
environment: ConformanceEnvironment,
|
|
299
|
+
options: AdapterConformanceOptions = {},
|
|
300
|
+
): Promise<AdapterConformanceReport> {
|
|
301
|
+
const streamDeadlineMs = options.streamDeadlineMs ?? DEFAULT_STREAM_DEADLINE_MS;
|
|
302
|
+
const pendingProbeMs = options.pendingProbeMs ?? DEFAULT_PENDING_PROBE_MS;
|
|
303
|
+
invariant(streamDeadlineMs > 0, 'streamDeadlineMs must be positive.');
|
|
304
|
+
invariant(pendingProbeMs > 0, 'pendingProbeMs must be positive.');
|
|
305
|
+
const results: AdapterConformanceResult[] = [];
|
|
306
|
+
const record = async (
|
|
307
|
+
id: AdapterConformanceFixtureId,
|
|
308
|
+
execute: () => Promise<Record<string, unknown>>,
|
|
309
|
+
): Promise<void> => {
|
|
310
|
+
try {
|
|
311
|
+
results.push({ id, ok: true, observations: await execute() });
|
|
312
|
+
} catch (error) {
|
|
313
|
+
results.push({
|
|
314
|
+
id,
|
|
315
|
+
ok: false,
|
|
316
|
+
observations: {},
|
|
317
|
+
error: error instanceof Error ? error.message : String(error),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
await environment.admin.reset();
|
|
323
|
+
const principalA = await environment.admin.createPrincipal('principal-a');
|
|
324
|
+
const principalB = await environment.admin.createPrincipal('principal-b');
|
|
325
|
+
const cubeA = await environment.admin.createCube('cube-a');
|
|
326
|
+
const cubeB = await environment.admin.createCube('cube-b');
|
|
327
|
+
await environment.admin.grantCube(principalA, cubeA);
|
|
328
|
+
await environment.admin.grantCube(principalB, cubeB);
|
|
329
|
+
const invitationA = await environment.admin.issueSingleUseInvitation(principalA);
|
|
330
|
+
const invitationB = await environment.admin.issueSingleUseInvitation(principalB);
|
|
331
|
+
|
|
332
|
+
let credentialA = '';
|
|
333
|
+
let credentialB = '';
|
|
334
|
+
let protocolBody: unknown;
|
|
335
|
+
let protocolInfo: ProtocolInfo | null = null;
|
|
336
|
+
await record('http.unauthenticated-liveness', async () => {
|
|
337
|
+
const response = await environment.operations.health();
|
|
338
|
+
expectStatus(response, 204, 'Unauthenticated liveness');
|
|
339
|
+
invariant(response.body === '' || response.body === undefined, 'Unauthenticated liveness exposed a response body.');
|
|
340
|
+
return { status: 204, bodyless: true };
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
await record('protocol.enrollment-auth', async () => {
|
|
344
|
+
expectError(await environment.operations.protocol(null), 401, ErrorCode.AUTH_MISSING, 'Unauthenticated protocol request');
|
|
345
|
+
const enrollmentARequest = createProtocolEnvelope('enroll-a1', {
|
|
346
|
+
invitation: invitationA,
|
|
347
|
+
client_name: 'conformance-a',
|
|
348
|
+
});
|
|
349
|
+
const enrollmentBRequest = createProtocolEnvelope('enroll-b1', {
|
|
350
|
+
invitation: invitationB,
|
|
351
|
+
client_name: 'conformance-b',
|
|
352
|
+
});
|
|
353
|
+
const enrolledAResponse = await environment.operations.enroll(enrollmentARequest);
|
|
354
|
+
const enrolledBResponse = await environment.operations.enroll(enrollmentBRequest);
|
|
355
|
+
expectStatus(enrolledAResponse, 201, 'Principal A enrollment');
|
|
356
|
+
expectStatus(enrolledBResponse, 201, 'Principal B enrollment');
|
|
357
|
+
credentialA = decodeEnrollmentExchangeResponseEnvelope(enrolledAResponse.body).payload.credential;
|
|
358
|
+
credentialB = decodeEnrollmentExchangeResponseEnvelope(enrolledBResponse.body).payload.credential;
|
|
359
|
+
invariant(credentialA !== credentialB, 'Enrollment issued the same credential to two principals.');
|
|
360
|
+
expectError(
|
|
361
|
+
await environment.operations.enroll(enrollmentARequest),
|
|
362
|
+
401,
|
|
363
|
+
ErrorCode.AUTH_INVALID,
|
|
364
|
+
'Invitation reuse',
|
|
365
|
+
);
|
|
366
|
+
const protocolResponse = await environment.operations.protocol(credentialA);
|
|
367
|
+
expectStatus(protocolResponse, 200, 'Authenticated protocol request');
|
|
368
|
+
protocolBody = protocolResponse.body;
|
|
369
|
+
protocolInfo = negotiateProtocol(decodeProtocolInfoEnvelope(protocolBody).payload, [
|
|
370
|
+
'log.cursor',
|
|
371
|
+
'stream.sse',
|
|
372
|
+
'stream.replay',
|
|
373
|
+
'acks',
|
|
374
|
+
'claims',
|
|
375
|
+
'decisions',
|
|
376
|
+
]);
|
|
377
|
+
return {
|
|
378
|
+
unauthenticated: ErrorCode.AUTH_MISSING,
|
|
379
|
+
enrollment_status: 201,
|
|
380
|
+
invitation_reuse: ErrorCode.AUTH_INVALID,
|
|
381
|
+
protocol_version: protocolInfo.protocol_version,
|
|
382
|
+
};
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await record('security.adapter-boundary-injection', async () => {
|
|
386
|
+
invariant(protocolInfo, 'Protocol fixture did not produce request limits.');
|
|
387
|
+
const injectedMessage = "'); DROP TABLE log_entries; --\r\ndata: forged-sse-frame";
|
|
388
|
+
const injectedBody = JSON.stringify(
|
|
389
|
+
createProtocolEnvelope('inject-b1', { message: injectedMessage }),
|
|
390
|
+
);
|
|
391
|
+
invariant(
|
|
392
|
+
utf8ByteLength(injectedBody) <= protocolInfo.limits.max_request_bytes &&
|
|
393
|
+
utf8ByteLength(injectedMessage) <= protocolInfo.limits.max_log_message_bytes,
|
|
394
|
+
'Injection fixture exceeded an advertised request limit.',
|
|
395
|
+
);
|
|
396
|
+
const injected = await environment.operations.appendRaw(
|
|
397
|
+
credentialB,
|
|
398
|
+
cubeB,
|
|
399
|
+
injectedBody,
|
|
400
|
+
);
|
|
401
|
+
expectStatus(injected, 201, 'Adapter-boundary injection append');
|
|
402
|
+
const injectedEntry = decodeAppendLogResultEnvelope(injected.body).payload.entry;
|
|
403
|
+
invariant(injectedEntry.message === injectedMessage, 'Adapter altered or interpreted injection-shaped log data.');
|
|
404
|
+
|
|
405
|
+
const sentinel = await environment.operations.append(
|
|
406
|
+
credentialB,
|
|
407
|
+
cubeB,
|
|
408
|
+
createProtocolEnvelope('inject-b2', { message: 'post-injection-sentinel' }),
|
|
409
|
+
);
|
|
410
|
+
expectStatus(sentinel, 201, 'Post-injection sentinel append');
|
|
411
|
+
const read = await environment.operations.read(
|
|
412
|
+
credentialB,
|
|
413
|
+
cubeB,
|
|
414
|
+
createProtocolEnvelope('inject-read', { cursor: null, limit: 10 }),
|
|
415
|
+
);
|
|
416
|
+
expectStatus(read, 200, 'Post-injection read');
|
|
417
|
+
const messages = decodeReadLogResultEnvelope(read.body).payload.entries.map((entry) => entry.message);
|
|
418
|
+
invariant(
|
|
419
|
+
same(messages, [injectedMessage, 'post-injection-sentinel']),
|
|
420
|
+
'Injection-shaped input was not persisted inertly and exactly.',
|
|
421
|
+
);
|
|
422
|
+
const opened = await environment.operations.openStream(credentialB, cubeB, null);
|
|
423
|
+
expectStatus(opened, 200, 'Post-injection stream open');
|
|
424
|
+
invariant(opened.stream, 'Post-injection stream omitted its AsyncIterable.');
|
|
425
|
+
const reader = new SseEventReader(opened.stream);
|
|
426
|
+
try {
|
|
427
|
+
const first = logEvent(
|
|
428
|
+
await within(reader.next(), 'Injected SSE event', streamDeadlineMs),
|
|
429
|
+
'Injected SSE event',
|
|
430
|
+
);
|
|
431
|
+
const second = logEvent(
|
|
432
|
+
await within(reader.next(), 'Post-injection sentinel SSE event', streamDeadlineMs),
|
|
433
|
+
'Post-injection sentinel SSE event',
|
|
434
|
+
);
|
|
435
|
+
invariant(
|
|
436
|
+
first.entry.message === injectedMessage && second.entry.message === 'post-injection-sentinel',
|
|
437
|
+
'Injection-shaped input escaped or split its SSE frame.',
|
|
438
|
+
);
|
|
439
|
+
const bookmark = await within(reader.next(), 'Post-injection bookmark', streamDeadlineMs);
|
|
440
|
+
invariant(bookmark.type === 'bookmark', 'Post-injection stream produced an extra forged event.');
|
|
441
|
+
} finally {
|
|
442
|
+
await reader.close();
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
status: 201,
|
|
446
|
+
preserved_exactly: true,
|
|
447
|
+
subsequent_write_succeeded: true,
|
|
448
|
+
ordered_messages: 2,
|
|
449
|
+
sse_events: 2,
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
await record('security.oversize-request', async () => {
|
|
454
|
+
invariant(protocolInfo, 'Protocol fixture did not produce request limits.');
|
|
455
|
+
const baseBody = JSON.stringify(
|
|
456
|
+
createProtocolEnvelope('oversize-a1', { message: 'must-not-persist' }),
|
|
457
|
+
);
|
|
458
|
+
const oversizedBody = baseBody + ' '.repeat(
|
|
459
|
+
Math.max(0, protocolInfo.limits.max_request_bytes - utf8ByteLength(baseBody) + 1),
|
|
460
|
+
);
|
|
461
|
+
invariant(
|
|
462
|
+
utf8ByteLength(oversizedBody) > protocolInfo.limits.max_request_bytes,
|
|
463
|
+
'Oversize fixture did not exceed max_request_bytes.',
|
|
464
|
+
);
|
|
465
|
+
const response = await environment.operations.appendRaw(credentialA, cubeA, oversizedBody);
|
|
466
|
+
expectError(response, 413, ErrorCode.CONTENT_TOO_LARGE, 'Oversized append request');
|
|
467
|
+
const read = await environment.operations.read(
|
|
468
|
+
credentialA,
|
|
469
|
+
cubeA,
|
|
470
|
+
createProtocolEnvelope('oversize-read', { cursor: null, limit: 10 }),
|
|
471
|
+
);
|
|
472
|
+
expectStatus(read, 200, 'Post-oversize read');
|
|
473
|
+
invariant(
|
|
474
|
+
decodeReadLogResultEnvelope(read.body).payload.entries.length === 0,
|
|
475
|
+
'Oversized request was persisted before rejection.',
|
|
476
|
+
);
|
|
477
|
+
return { status: 413, code: ErrorCode.CONTENT_TOO_LARGE, persisted_entries: 0 };
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
await record('security.cross-cube-isolation', async () => {
|
|
481
|
+
const secretAppend = await environment.operations.append(
|
|
482
|
+
credentialB,
|
|
483
|
+
cubeB,
|
|
484
|
+
createProtocolEnvelope('append-b1', { message: 'principal-b-secret' }),
|
|
485
|
+
);
|
|
486
|
+
expectStatus(secretAppend, 201, 'Principal B append');
|
|
487
|
+
const denied = await environment.operations.read(
|
|
488
|
+
credentialA,
|
|
489
|
+
cubeB,
|
|
490
|
+
createProtocolEnvelope('read-cross', { cursor: null, limit: 10 }),
|
|
491
|
+
);
|
|
492
|
+
expectError(denied, 404, ErrorCode.NOT_FOUND, 'Cross-cube read');
|
|
493
|
+
return { status: 404, code: ErrorCode.NOT_FOUND };
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
const entries: Array<{ id: string; created_at: string; message: string }> = [];
|
|
497
|
+
let readCursor: LogCursor | null = null;
|
|
498
|
+
await record('log.read-cursor-tuple', async () => {
|
|
499
|
+
for (const [index, message] of ['alpha', 'beta', 'gamma'].entries()) {
|
|
500
|
+
const response = await environment.operations.append(
|
|
501
|
+
credentialA,
|
|
502
|
+
cubeA,
|
|
503
|
+
createProtocolEnvelope(`append-a${index + 1}`, { message }),
|
|
504
|
+
);
|
|
505
|
+
expectStatus(response, 201, `Append ${message}`);
|
|
506
|
+
const entry = decodeAppendLogResultEnvelope(response.body).payload.entry;
|
|
507
|
+
entries.push({ id: entry.id, created_at: entry.created_at, message: entry.message });
|
|
508
|
+
}
|
|
509
|
+
const response = await environment.operations.read(
|
|
510
|
+
credentialA,
|
|
511
|
+
cubeA,
|
|
512
|
+
createProtocolEnvelope('read-page1', { cursor: null, limit: 2 }),
|
|
513
|
+
);
|
|
514
|
+
expectStatus(response, 200, 'First paged read');
|
|
515
|
+
const page = decodeReadLogResultEnvelope(response.body).payload;
|
|
516
|
+
invariant(same(page.entries.map((entry) => entry.message), ['alpha', 'beta']), 'Read did not honor limit and tuple ordering.');
|
|
517
|
+
invariant(page.has_more && page.behind_by === 1, 'Read pagination metadata is incorrect.');
|
|
518
|
+
invariant(page.cursor !== null, 'Read did not return a cursor.');
|
|
519
|
+
invariant(
|
|
520
|
+
page.cursor.id === entries[1].id && page.cursor.created_at === entries[1].created_at,
|
|
521
|
+
'Read cursor does not equal the final delivered entry tuple.',
|
|
522
|
+
);
|
|
523
|
+
readCursor = page.cursor;
|
|
524
|
+
return { messages: ['alpha', 'beta'], has_more: true, behind_by: 1, cursor_matches_last_entry: true };
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
let liveCursor: LogCursor | null = null;
|
|
528
|
+
await record('sse.replay-live-transition', async () => {
|
|
529
|
+
invariant(readCursor, 'Cursor fixture did not produce a cursor.');
|
|
530
|
+
const barrier = environment.admin.armReplayTransition();
|
|
531
|
+
const openPromise = environment.operations.openStream(credentialA, cubeA, readCursor);
|
|
532
|
+
await within(barrier.reached, 'Replay transition boundary', streamDeadlineMs);
|
|
533
|
+
try {
|
|
534
|
+
const appendDelta = environment.operations.append(
|
|
535
|
+
credentialA,
|
|
536
|
+
cubeA,
|
|
537
|
+
createProtocolEnvelope('append-a4', { message: 'delta' }),
|
|
538
|
+
);
|
|
539
|
+
expectStatus(await appendDelta, 201, 'Transition append');
|
|
540
|
+
} finally {
|
|
541
|
+
barrier.release();
|
|
542
|
+
}
|
|
543
|
+
const opened = await within(openPromise, 'Cursor stream open', streamDeadlineMs);
|
|
544
|
+
expectStatus(opened, 200, 'Cursor stream open');
|
|
545
|
+
invariant(opened.stream, 'Successful stream response omitted its AsyncIterable.');
|
|
546
|
+
const reader = new SseEventReader(opened.stream);
|
|
547
|
+
try {
|
|
548
|
+
const replay = logEvent(await within(reader.next(), 'Replay event', streamDeadlineMs), 'Replay');
|
|
549
|
+
invariant(replay.entry.message === 'gamma', 'Stream ignored its cursor or replayed the wrong entry.');
|
|
550
|
+
invariant(compareLogCursor(readCursor, replay.cursor) < 0, 'Replay cursor did not advance.');
|
|
551
|
+
const delta = logEvent(await within(reader.next(), 'Transition delta event', streamDeadlineMs), 'Replay transition');
|
|
552
|
+
invariant(delta.entry.message === 'delta', 'Entry appended at the replay/live boundary was lost.');
|
|
553
|
+
invariant(compareLogCursor(replay.cursor, delta.cursor) < 0, 'Transition cursor did not advance.');
|
|
554
|
+
const bookmark = await within(reader.next(), 'Replay-complete bookmark', streamDeadlineMs);
|
|
555
|
+
invariant(bookmark.type === 'bookmark' && bookmark.replay_complete, 'Stream omitted its replay-complete bookmark.');
|
|
556
|
+
const noDuplicate = reader.next();
|
|
557
|
+
await provePending(noDuplicate, 'Live stream after delta', pendingProbeMs);
|
|
558
|
+
const epsilonResponse = await environment.operations.append(
|
|
559
|
+
credentialA,
|
|
560
|
+
cubeA,
|
|
561
|
+
createProtocolEnvelope('append-a5', { message: 'epsilon' }),
|
|
562
|
+
);
|
|
563
|
+
expectStatus(epsilonResponse, 201, 'Live append');
|
|
564
|
+
const epsilon = logEvent(await within(noDuplicate, 'Live epsilon event', streamDeadlineMs), 'Live append');
|
|
565
|
+
invariant(epsilon.entry.message === 'epsilon', 'Live stream duplicated or reordered an event.');
|
|
566
|
+
invariant(compareLogCursor(delta.cursor, epsilon.cursor) < 0, 'Live stream cursors are not ordered.');
|
|
567
|
+
invariant(new Set([replay.entry.id, delta.entry.id, epsilon.entry.id]).size === 3, 'Stream delivered a duplicate entry.');
|
|
568
|
+
liveCursor = epsilon.cursor;
|
|
569
|
+
return { replay: ['gamma'], transition: 'bookmark', live: ['delta', 'epsilon'], duplicates: 0 };
|
|
570
|
+
} finally {
|
|
571
|
+
await reader.close();
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
await record('cursor.explicit-expiry', async () => {
|
|
576
|
+
invariant(readCursor, 'Cursor fixture did not produce a cursor.');
|
|
577
|
+
await environment.admin.expireCursor(cubeA, readCursor);
|
|
578
|
+
const response = await environment.operations.read(
|
|
579
|
+
credentialA,
|
|
580
|
+
cubeA,
|
|
581
|
+
createProtocolEnvelope('read-expired', { cursor: readCursor, limit: 10 }),
|
|
582
|
+
);
|
|
583
|
+
expectError(response, 410, ErrorCode.CURSOR_EXPIRED, 'Expired cursor read');
|
|
584
|
+
const stream = await environment.operations.openStream(credentialA, cubeA, readCursor);
|
|
585
|
+
expectError(stream, 410, ErrorCode.CURSOR_EXPIRED, 'Expired cursor stream');
|
|
586
|
+
invariant(stream.stream === null, 'Expired cursor stream exposed a body stream.');
|
|
587
|
+
return { read_status: 410, stream_status: 410, code: ErrorCode.CURSOR_EXPIRED };
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await record('acks.idempotent', async () => {
|
|
591
|
+
invariant(entries[0], 'Append fixture did not produce an entry.');
|
|
592
|
+
const request = createProtocolEnvelope('ack-entry1', { entry_id: entries[0].id, kind: 'ack' });
|
|
593
|
+
const first = await environment.operations.ack(credentialA, cubeA, request);
|
|
594
|
+
const second = await environment.operations.ack(credentialA, cubeA, request);
|
|
595
|
+
expectStatus(first, 204, 'First acknowledgement');
|
|
596
|
+
expectStatus(second, 204, 'Repeated acknowledgement');
|
|
597
|
+
invariant((first.body === '' || first.body === undefined) && (second.body === '' || second.body === undefined), 'Acknowledgement responses must be bodyless.');
|
|
598
|
+
return { first_status: 204, repeated_status: 204, bodyless: true };
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
await record('claims.durable-noncursor', async () => {
|
|
602
|
+
invariant(entries[1] && liveCursor, 'Log fixtures did not produce claim state.');
|
|
603
|
+
const claim = await environment.operations.ack(
|
|
604
|
+
credentialA,
|
|
605
|
+
cubeA,
|
|
606
|
+
createProtocolEnvelope('claim-entry2', { entry_id: entries[1].id, kind: 'claim' }),
|
|
607
|
+
);
|
|
608
|
+
expectStatus(claim, 204, 'Claim');
|
|
609
|
+
const read = await environment.operations.read(
|
|
610
|
+
credentialA,
|
|
611
|
+
cubeA,
|
|
612
|
+
createProtocolEnvelope('read-claims', { cursor: liveCursor, limit: 10 }),
|
|
613
|
+
);
|
|
614
|
+
expectStatus(read, 200, 'Claim-state read');
|
|
615
|
+
const page = decodeReadLogResultEnvelope(read.body).payload;
|
|
616
|
+
invariant(page.entries.length === 0, 'Claim unexpectedly created a log entry.');
|
|
617
|
+
invariant(page.cursor !== null && compareLogCursor(page.cursor, liveCursor) === 0, 'Claim advanced the log cursor.');
|
|
618
|
+
invariant(page.claims.some((item) => item.log_entry_id === entries[1].id), 'Claim was not durable in a later read.');
|
|
619
|
+
return { durable_claims: 1, entries: 0, cursor_advanced: false };
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
await record('decisions.topic-supersession', async () => {
|
|
623
|
+
const firstResponse = await environment.operations.recordDecision(
|
|
624
|
+
credentialA,
|
|
625
|
+
cubeA,
|
|
626
|
+
createProtocolEnvelope('decision1', { topic: 'runtime', decision: 'first' }),
|
|
627
|
+
);
|
|
628
|
+
expectStatus(firstResponse, 201, 'First decision');
|
|
629
|
+
const first = decodeDecisionResultEnvelope(firstResponse.body).payload.decision;
|
|
630
|
+
const secondResponse = await environment.operations.recordDecision(
|
|
631
|
+
credentialA,
|
|
632
|
+
cubeA,
|
|
633
|
+
createProtocolEnvelope('decision2', { topic: 'runtime', decision: 'second', rationale: 'new evidence' }),
|
|
634
|
+
);
|
|
635
|
+
expectStatus(secondResponse, 201, 'Superseding decision');
|
|
636
|
+
const second = decodeDecisionResultEnvelope(secondResponse.body).payload.decision;
|
|
637
|
+
const listResponse = await environment.operations.listDecisions(
|
|
638
|
+
credentialA,
|
|
639
|
+
cubeA,
|
|
640
|
+
createProtocolEnvelope('decisions', {}),
|
|
641
|
+
);
|
|
642
|
+
expectStatus(listResponse, 200, 'Decision list');
|
|
643
|
+
const active = decodeDecisionsResultEnvelope(listResponse.body).payload.decisions;
|
|
644
|
+
invariant(active.length === 1 && active[0].decision === 'second', 'Decision list did not contain only the active superseding decision.');
|
|
645
|
+
invariant(second.supersedes === first.id, 'Superseding decision did not reference its predecessor.');
|
|
646
|
+
return { active_count: 1, active_decision: 'second', supersedes_first: true };
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
await record('capabilities.unsupported-fails-closed', async () => {
|
|
650
|
+
invariant(protocolBody, 'Protocol fixture did not produce an envelope.');
|
|
651
|
+
let code: ErrorCode | null = null;
|
|
652
|
+
try {
|
|
653
|
+
negotiateProtocol(
|
|
654
|
+
decodeProtocolInfoEnvelope(protocolBody).payload,
|
|
655
|
+
['future.required' as Capability],
|
|
656
|
+
);
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if (error instanceof ProtocolContractError) code = error.code;
|
|
659
|
+
else throw error;
|
|
660
|
+
}
|
|
661
|
+
invariant(code === ErrorCode.UNSUPPORTED_CAPABILITY, 'Unsupported capability did not fail closed client-side.');
|
|
662
|
+
return { code };
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
await record('security.active-stream-revocation', async () => {
|
|
666
|
+
invariant(liveCursor, 'Stream fixture did not produce a live cursor.');
|
|
667
|
+
const opened = await environment.operations.openStream(credentialA, cubeA, liveCursor);
|
|
668
|
+
expectStatus(opened, 200, 'Revocation stream open');
|
|
669
|
+
invariant(opened.stream, 'Successful revocation stream omitted its AsyncIterable.');
|
|
670
|
+
const reader = new SseEventReader(opened.stream);
|
|
671
|
+
try {
|
|
672
|
+
const bookmark = await within(reader.next(), 'Initial replay-complete bookmark', streamDeadlineMs);
|
|
673
|
+
invariant(bookmark.type === 'bookmark' && bookmark.replay_complete, 'Fresh live stream did not complete replay.');
|
|
674
|
+
const pending = reader.next();
|
|
675
|
+
await provePending(pending, 'Idle live stream', pendingProbeMs);
|
|
676
|
+
await environment.admin.revokePrincipal(principalA);
|
|
677
|
+
let terminated = false;
|
|
678
|
+
try {
|
|
679
|
+
await within(pending, 'Revoked stream termination', streamDeadlineMs);
|
|
680
|
+
} catch (error) {
|
|
681
|
+
if (error instanceof Error && error.message.includes('did not settle')) throw error;
|
|
682
|
+
terminated = true;
|
|
683
|
+
}
|
|
684
|
+
invariant(terminated, 'Revoked stream yielded data instead of terminating.');
|
|
685
|
+
} finally {
|
|
686
|
+
await reader.close();
|
|
687
|
+
}
|
|
688
|
+
const rejected = await environment.operations.read(
|
|
689
|
+
credentialA,
|
|
690
|
+
cubeA,
|
|
691
|
+
createProtocolEnvelope('read-revoked', { cursor: liveCursor, limit: 10 }),
|
|
692
|
+
);
|
|
693
|
+
expectError(rejected, 401, ErrorCode.SESSION_REVOKED, 'Post-revocation request');
|
|
694
|
+
return { stream_terminated: true, subsequent_status: 401, subsequent_code: ErrorCode.SESSION_REVOKED };
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
const normalizedTranscript = results.map(({ id, observations }) => ({ id, observations }));
|
|
698
|
+
return { ok: results.every((result) => result.ok), results, normalizedTranscript };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export async function runEquivalentAdapterConformance(
|
|
702
|
+
cloud: ConformanceEnvironment,
|
|
703
|
+
local: ConformanceEnvironment,
|
|
704
|
+
options: AdapterConformanceOptions = {},
|
|
705
|
+
): Promise<EquivalentAdapterConformanceReport> {
|
|
706
|
+
const [cloudReport, localReport] = await Promise.all([
|
|
707
|
+
runAdapterConformance(cloud, options),
|
|
708
|
+
runAdapterConformance(local, options),
|
|
709
|
+
]);
|
|
710
|
+
const equivalent = same(cloudReport.normalizedTranscript, localReport.normalizedTranscript);
|
|
711
|
+
return {
|
|
712
|
+
ok: cloudReport.ok && localReport.ok && equivalent,
|
|
713
|
+
cloud: cloudReport,
|
|
714
|
+
local: localReport,
|
|
715
|
+
equivalent,
|
|
716
|
+
};
|
|
717
|
+
}
|