@openwop/openwop-conformance 2.37.0 → 2.38.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 +20 -0
- package/README.md +3 -3
- package/dist/cli.js +8 -18
- package/dist/lib/certification-bundle-v3.js +46 -18
- package/dist/lib/jcs.js +274 -0
- package/dist/lib/requirement-ledger.js +44 -3
- package/dist/lib/scenario-disposition.js +37 -8
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +323 -57
- package/scenario-majors.json +7 -3
- package/schemas/CORPUS-STAMP.json +30 -30
- package/src/cli.ts +8 -16
- package/src/lib/certification-bundle-v3.ts +42 -17
- package/src/lib/front-mux.ts +44 -2
- package/src/lib/jcs.ts +229 -0
- package/src/lib/llm-cache-key-recipe.ts +10 -20
- package/src/lib/requirement-ledger.ts +82 -4
- package/src/lib/scenario-disposition.ts +41 -3
- package/src/lib/scoped-receiver.ts +223 -0
- package/src/lib/triggerBridge.ts +49 -0
- package/src/scenarios/auth-subject-link.test.ts +18 -1
- package/src/scenarios/jcs-vectors.test.ts +108 -0
- package/src/scenarios/semantic-digest-vectors.test.ts +8 -0
- package/src/scenarios/trigger-bridge-delivery.test.ts +17 -2
- package/src/scenarios/trigger-stream-cdc-sources.test.ts +17 -2
- package/src/scenarios/v2-a2a-operation-map.test.ts +28 -0
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +18 -4
- package/src/scenarios/v2-bound-id-kinds.test.ts +35 -22
- package/src/scenarios/v2-content-locale-keys.test.ts +10 -1
- package/src/scenarios/v2-idempotency-in-flight.test.ts +81 -26
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +7 -1
- package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +7 -1
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +31 -34
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +63 -47
- package/src/scenarios/webhook-signed-delivery.test.ts +55 -42
- package/src/setup.ts +24 -4
- package/vectors/jcs-v1.json +294 -0
- package/vectors/semantic-request-digest-v2.json +52 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A webhook receiver that owns its destination — one exercise, one identity.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this file exists (2.37.0) ────────────────────────────────────────────
|
|
5
|
+
* Five surfaces in this suite bind `OPENWOP_WEBHOOK_RECEIVER_PORT`, and on a
|
|
6
|
+
* tunnelled cut four of them registered `resolveRegistrationUrl(...)`, which
|
|
7
|
+
* returns `OPENWOP_WEBHOOK_RECEIVER_URL` VERBATIM — one byte-identical string
|
|
8
|
+
* for every caller. `v2-bound-id-kinds`, `v2-webhook-delivery-shape`,
|
|
9
|
+
* `v2-webhook-durable-delivery` and `webhook-signed-delivery` therefore pointed
|
|
10
|
+
* four subscriptions at ONE destination.
|
|
11
|
+
*
|
|
12
|
+
* A webhook subscription is durable host-side state. It keeps delivering, and
|
|
13
|
+
* RETRYING, after the file that created it has finished, so the collision is not
|
|
14
|
+
* only between two live listeners — it is between an exercise and the leftovers
|
|
15
|
+
* of an earlier one. Whichever receiver held the pinned port read those
|
|
16
|
+
* leftovers as its own traffic, and `v2-webhook-durable-delivery` answers 500 BY
|
|
17
|
+
* DESIGN for the first attempts of every delivery key, so the exercise it landed
|
|
18
|
+
* on saw failures it never caused. `v2-webhook-delivery-shape`'s own
|
|
19
|
+
* `startReceiver` carried a comment describing the symptom from the other end:
|
|
20
|
+
* "the tunnel forwards to the PINNED port — held by the other receiver, which
|
|
21
|
+
* answers 500 by design — so this file's `deliveries` stays empty, its legs
|
|
22
|
+
* soft-skip, and the rows resolve `executed-pass`." A wire-shape scenario that
|
|
23
|
+
* never opened a delivery body went green.
|
|
24
|
+
*
|
|
25
|
+
* This is the class openwop#1513 (two legs, one effect identity) and
|
|
26
|
+
* openwop#1520 (many fakes, one public front) already opened. Those two supply
|
|
27
|
+
* the halves, and this composes them rather than reimplementing either:
|
|
28
|
+
*
|
|
29
|
+
* - ROUTING is `front-mux.ts`. Several receivers can be alive behind one
|
|
30
|
+
* front; each registers its handler under its nonce and calls `routeFronted`
|
|
31
|
+
* first, so a delivery reaches the receiver it was ADDRESSED to whichever
|
|
32
|
+
* listener happens to hold the port.
|
|
33
|
+
* - IDENTITY is here, and it is why the nonce is UNCONDITIONAL rather than
|
|
34
|
+
* `frontedEndpoint`'s "only when this fake does not own the pinned port".
|
|
35
|
+
* Routing alone does not close a webhook: a bare front is one identity
|
|
36
|
+
* shared ACROSS TIME, so a retry aimed at a finished exercise arrives
|
|
37
|
+
* indistinguishable from this one's traffic. With the nonce always present
|
|
38
|
+
* that retry addresses a nonce this listener does not serve, `routeFronted`
|
|
39
|
+
* answers it 404, and it is counted as `foreign()` — never as a delivery.
|
|
40
|
+
*
|
|
41
|
+
* What this does NOT do is relax any gate. The destination is still the
|
|
42
|
+
* operator's own front or loopback; `resolvePublicFront` still refuses a
|
|
43
|
+
* non-https or private front; `receiverBinding` still binds loopback unless the
|
|
44
|
+
* operator declared otherwise. Only the PATH changed.
|
|
45
|
+
*
|
|
46
|
+
* @see lib/front-mux.ts (routing), lib/effect-receiver.ts (the same nonce idea
|
|
47
|
+
* for an outbound effect's Layer-2 identity)
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { randomBytes } from 'node:crypto';
|
|
51
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
52
|
+
import { FRONT_MUX_PREFIX, registerBehindFront, routeFronted, unregisterBehindFront, withoutFrontPath } from './front-mux.js';
|
|
53
|
+
import { receiverBinding, resolvePublicFront } from './webhook-receiver.js';
|
|
54
|
+
|
|
55
|
+
/** The operator's public front for the suite's webhook receiver. */
|
|
56
|
+
export const WEBHOOK_FRONT_ENV = 'OPENWOP_WEBHOOK_RECEIVER_URL';
|
|
57
|
+
|
|
58
|
+
type HeaderBag = Record<string, string | string[] | undefined>;
|
|
59
|
+
|
|
60
|
+
/** One request addressed to THIS exercise. */
|
|
61
|
+
export interface ScopedHit {
|
|
62
|
+
/** The path as this receiver sees it — the `/fx/<nonce>` prefix already stripped. */
|
|
63
|
+
readonly path: string;
|
|
64
|
+
readonly method: string;
|
|
65
|
+
readonly headers: HeaderBag;
|
|
66
|
+
readonly body: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ScopedReceiver {
|
|
70
|
+
readonly server: Server;
|
|
71
|
+
/**
|
|
72
|
+
* The destination to REGISTER with the host: the public front when one is
|
|
73
|
+
* wired, else this listener's own address — this exercise's nonce path
|
|
74
|
+
* appended either way.
|
|
75
|
+
*/
|
|
76
|
+
readonly url: string;
|
|
77
|
+
readonly tunnelled: boolean;
|
|
78
|
+
/** The local address this listener actually answers on — for failure detail. */
|
|
79
|
+
readonly localUrl: string;
|
|
80
|
+
/** What makes this exercise's destination, and so its subscription, its own. */
|
|
81
|
+
readonly nonce: string;
|
|
82
|
+
/** The port this listener bound — the pinned one when the operator pinned it. */
|
|
83
|
+
readonly port: number;
|
|
84
|
+
/**
|
|
85
|
+
* Requests that reached this listener bearing some OTHER exercise's nonce, or
|
|
86
|
+
* none at all. Reported so a scenario can say WHY it saw nothing; never handed
|
|
87
|
+
* to the scenario's recorder.
|
|
88
|
+
*/
|
|
89
|
+
foreign(): number;
|
|
90
|
+
close(): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Start a receiver for exactly one exercise.
|
|
95
|
+
*
|
|
96
|
+
* `respond` is called ONLY for this exercise's own requests, and sees the path
|
|
97
|
+
* with the nonce prefix stripped — exactly what it would see on a listener of
|
|
98
|
+
* its own. It owns the response.
|
|
99
|
+
*/
|
|
100
|
+
export async function startScopedReceiver(
|
|
101
|
+
respond: (hit: ScopedHit, res: ServerResponse) => void,
|
|
102
|
+
): Promise<ScopedReceiver> {
|
|
103
|
+
const nonce = randomBytes(9).toString('hex');
|
|
104
|
+
let foreign = 0;
|
|
105
|
+
const own = (request: IncomingMessage, res: ServerResponse): void => {
|
|
106
|
+
const chunks: Buffer[] = [];
|
|
107
|
+
request.on('data', (c: Buffer) => chunks.push(c));
|
|
108
|
+
request.on('end', () => {
|
|
109
|
+
respond(
|
|
110
|
+
{
|
|
111
|
+
path: request.url ?? '/',
|
|
112
|
+
method: request.method ?? '',
|
|
113
|
+
headers: request.headers,
|
|
114
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
115
|
+
},
|
|
116
|
+
res,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
registerBehindFront(WEBHOOK_FRONT_ENV, nonce, own);
|
|
121
|
+
const server = createServer((request: IncomingMessage, res: ServerResponse) => {
|
|
122
|
+
// Counted BEFORE routing: `routeFronted` answers an unknown nonce itself and
|
|
123
|
+
// cannot report that it did, and a request that is not this exercise's must
|
|
124
|
+
// still be visible in the failure detail.
|
|
125
|
+
if (!withoutFrontPath(WEBHOOK_FRONT_ENV, request.url ?? '/').startsWith(`${FRONT_MUX_PREFIX}${nonce}`)) foreign += 1;
|
|
126
|
+
if (routeFronted(WEBHOOK_FRONT_ENV, request, res)) return;
|
|
127
|
+
// Not an `/fx/` path at all: a stranger, or a host that dropped the path it
|
|
128
|
+
// was given. Answered, never recorded — a receiver that counts what it was
|
|
129
|
+
// not addressed to is the defect this helper exists to remove.
|
|
130
|
+
request.resume();
|
|
131
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
132
|
+
res.end(JSON.stringify({ error: 'this receiver serves one conformance exercise; address its nonce path' }));
|
|
133
|
+
});
|
|
134
|
+
const pinned = Number(process.env['OPENWOP_WEBHOOK_RECEIVER_PORT'] ?? '');
|
|
135
|
+
const bindPort = Number.isInteger(pinned) && pinned > 0 && pinned < 65536 ? pinned : 0;
|
|
136
|
+
const binding = receiverBinding();
|
|
137
|
+
await new Promise<void>((resolve) => server.listen(bindPort, binding.bind, () => resolve()));
|
|
138
|
+
const addr = server.address();
|
|
139
|
+
if (typeof addr !== 'object' || addr === null) throw new Error('receiver address unavailable');
|
|
140
|
+
const origin = `http://${binding.advertise}:${addr.port}`;
|
|
141
|
+
const front = resolvePublicFront(WEBHOOK_FRONT_ENV, origin);
|
|
142
|
+
return {
|
|
143
|
+
server,
|
|
144
|
+
url: `${front.url.replace(/\/+$/, '')}${FRONT_MUX_PREFIX}${nonce}`,
|
|
145
|
+
tunnelled: front.tunnelled,
|
|
146
|
+
localUrl: `${origin}${FRONT_MUX_PREFIX}${nonce}`,
|
|
147
|
+
nonce,
|
|
148
|
+
port: addr.port,
|
|
149
|
+
foreign: () => foreign,
|
|
150
|
+
close: () =>
|
|
151
|
+
new Promise<void>((resolve) => {
|
|
152
|
+
unregisterBehindFront(WEBHOOK_FRONT_ENV, nonce);
|
|
153
|
+
server.close(() => resolve());
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A destination for an exercise that must REGISTER a subscription but wants no
|
|
160
|
+
* delivery — the mint leg of `v2-bound-id-kinds`, say, which needs a 201 and a
|
|
161
|
+
* bound id and nothing else.
|
|
162
|
+
*
|
|
163
|
+
* Such a leg still has to honour the operator's front, or its registration is
|
|
164
|
+
* refused by an SSRF guard doing its job and the leg records `blocked` on every
|
|
165
|
+
* public cut. But handing it the front VERBATIM gives it the same identity as
|
|
166
|
+
* every other exercise, and a subscription is live from the 201 until the
|
|
167
|
+
* DELETE: anything the host fans out in that window lands on whichever listener
|
|
168
|
+
* holds the port and is read as that exercise's traffic.
|
|
169
|
+
*
|
|
170
|
+
* So it gets a nonce too. No receiver serves it, which is the point: a delivery
|
|
171
|
+
* to this destination is answered 404 by whichever scoped receiver owns the
|
|
172
|
+
* port rather than being absorbed into that exercise's record.
|
|
173
|
+
*/
|
|
174
|
+
export function unservedDestination(fallbackUrl: string): { url: string; tunnelled: boolean; nonce: string } {
|
|
175
|
+
const nonce = randomBytes(9).toString('hex');
|
|
176
|
+
const front = resolvePublicFront(WEBHOOK_FRONT_ENV, fallbackUrl);
|
|
177
|
+
return {
|
|
178
|
+
url: `${front.url.replace(/\/+$/, '')}${FRONT_MUX_PREFIX}${nonce}`,
|
|
179
|
+
tunnelled: front.tunnelled,
|
|
180
|
+
nonce,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Why a scoped receiver saw NO request of its own — the cause a `blocked` row
|
|
186
|
+
* must name (RFC 0148 §A: anything other than `executed-pass` MUST say why).
|
|
187
|
+
*
|
|
188
|
+
* A zero here is not by itself evidence that the host failed to deliver, and
|
|
189
|
+
* `foreign()` is what tells the two apart:
|
|
190
|
+
*
|
|
191
|
+
* - `foreign() > 0` — traffic DID reach this listener, addressed to another
|
|
192
|
+
* nonce or to no nonce at all. The path between the host and this process
|
|
193
|
+
* works, so "the host did not deliver" is not what was observed; what was
|
|
194
|
+
* observed is that nothing arrived under THIS exercise's identity. That is
|
|
195
|
+
* unmeasured, not unmet, and callers record `blocked`.
|
|
196
|
+
* - `foreign() === 0` — nothing reached this listener at all. That is still
|
|
197
|
+
* ambiguous (a front not wired to this process, or a host that never called
|
|
198
|
+
* out), and callers keep whichever disposition the leg already carried; this
|
|
199
|
+
* function only supplies the sentence that says which address was in play.
|
|
200
|
+
*
|
|
201
|
+
* Either way the record names the observation rather than a conclusion, so a
|
|
202
|
+
* reader can act on it without hand-probing production.
|
|
203
|
+
*/
|
|
204
|
+
export function noDeliveryCause(rx: ScopedReceiver, what = 'delivery'): string {
|
|
205
|
+
const where = rx.tunnelled
|
|
206
|
+
? `the registered destination is the public front ${rx.url} (${WEBHOOK_FRONT_ENV}) and this listener answers on ${rx.localUrl}, port ${rx.port}`
|
|
207
|
+
: `the registered destination is this listener at ${rx.url}, port ${rx.port}`;
|
|
208
|
+
const strangers = rx.foreign();
|
|
209
|
+
if (strangers > 0) {
|
|
210
|
+
return `no ${what} bearing this exercise's nonce ${rx.nonce} arrived, but ${strangers} request(s) DID reach this listener addressed elsewhere — so the path from the host to this process works and the absence is of this exercise's identity, not of traffic (${where})`;
|
|
211
|
+
}
|
|
212
|
+
return `no ${what} bearing this exercise's nonce ${rx.nonce} arrived, and nothing else reached this listener either — ${where}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* True when a zero observation is provably NOT a verdict about the host: other
|
|
217
|
+
* traffic reached this listener, so the absence is of this exercise's identity
|
|
218
|
+
* rather than of delivery. Callers record `blocked` (which denies certification
|
|
219
|
+
* exactly as a failure does, RFC 0168 §E.1) instead of convicting the host.
|
|
220
|
+
*/
|
|
221
|
+
export function absenceIsUnmeasured(rx: ScopedReceiver): boolean {
|
|
222
|
+
return rx.foreign() > 0;
|
|
223
|
+
}
|
package/src/lib/triggerBridge.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* @see spec/v1/trigger-bridge.md
|
|
21
21
|
* @see spec/v1/profiles.md (§openwop-trigger-bridge)
|
|
22
22
|
*/
|
|
23
|
+
import { randomBytes, randomInt } from 'node:crypto';
|
|
23
24
|
import { driver } from './driver.js';
|
|
24
25
|
import { deriveProfiles, type DiscoveryPayload } from './profiles.js';
|
|
25
26
|
|
|
@@ -70,5 +71,53 @@ export async function driveDelivery(
|
|
|
70
71
|
return (res.json as DeliveryResult | undefined) ?? {};
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A dedup key that belongs to ONE exercise (2.37.0).
|
|
76
|
+
*
|
|
77
|
+
* `trigger-bridge.md` §C-1 makes the dedup window a ≥24h FLOOR, so a LITERAL
|
|
78
|
+
* dedup key is not a fixture — it is a durable identity the host is required to
|
|
79
|
+
* remember across runs of this suite. Two exercises that hand the bridge the
|
|
80
|
+
* same key are ONE delivery by the spec's own rule, and the second one's row
|
|
81
|
+
* reads zero deliveries on a host doing exactly what it MUST. That is a suite
|
|
82
|
+
* defect and not a host defect, and it is the same failure the RFC 0158
|
|
83
|
+
* duplicate-delivery row had when two scenario files shared one effect identity
|
|
84
|
+
* (`lib/effect-receiver.ts`).
|
|
85
|
+
*
|
|
86
|
+
* `prefix` keeps a key readable in a host's own log; the random half is what
|
|
87
|
+
* makes it this exercise's. `randomBytes`, not `Date.now()`: two vitest workers
|
|
88
|
+
* can enter the same line in the same millisecond.
|
|
89
|
+
*/
|
|
90
|
+
export function freshDedupKey(prefix: string): string {
|
|
91
|
+
return `openwop-conformance-${prefix}-${randomBytes(9).toString('hex')}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The same per-exercise mint for a `stream` source, in the COORDINATE shape
|
|
96
|
+
* §F.5 keys on (2.37.0).
|
|
97
|
+
*
|
|
98
|
+
* `trigger-stream-cdc-sources.test.ts` handed the seam the literal
|
|
99
|
+
* `'events:3:99001'` and then asserted `deliveredCount === 1 || outcome ===
|
|
100
|
+
* 'delivered'` — the identical defect `freshDedupKey` above was written for,
|
|
101
|
+
* one file over, found when the enumeration was re-run against the merged
|
|
102
|
+
* tree. §C-1's window is a ≥24h floor and §F.5 reuses it verbatim, so the
|
|
103
|
+
* second run of that file against the same host, any time that day, hands the
|
|
104
|
+
* bridge broker coordinates it has already delivered; a CONFORMANT host
|
|
105
|
+
* collapses the exercise into the first run's outcome and the assertion
|
|
106
|
+
* convicts it. Cold host passes, warm host fails.
|
|
107
|
+
*
|
|
108
|
+
* `freshDedupKey` would not do here, and the difference is not cosmetic: §F.5
|
|
109
|
+
* says a stream event's dedup key SHOULD derive from
|
|
110
|
+
* `(topic, partition, offset)`, and the leg's own `req()` message asserts over
|
|
111
|
+
* exactly that keying. An opaque token would make the message describe
|
|
112
|
+
* something the call no longer does. So topic and partition stay fixed and the
|
|
113
|
+
* OFFSET is minted — which is precisely what makes a real broker's message a
|
|
114
|
+
* different message.
|
|
115
|
+
*/
|
|
116
|
+
export function freshStreamDedupKey(topic = 'events', partition = 3): string {
|
|
117
|
+
// A 2^44 offset space: distinct across every run this suite will make, and
|
|
118
|
+
// still a plausible broker offset rather than an opaque token.
|
|
119
|
+
return `${topic}:${partition}:${randomInt(2 ** 44)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
73
122
|
export const SUBSCRIPTION_STATES = ['active', 'paused', 'failed', 'dead-lettered'];
|
|
74
123
|
export const DELIVERY_OUTCOMES = ['delivered', 'retrying', 'dead-lettered'];
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* @see spec/v1/auth-profiles.md §"Subject linking (SAML ⟷ SCIM)"
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { randomBytes } from 'node:crypto';
|
|
26
27
|
import { describe, it, expect } from 'vitest';
|
|
27
28
|
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
28
29
|
import { driver } from '../lib/driver.js';
|
|
@@ -109,7 +110,23 @@ describe('auth-subject-link: cross-lane deactivation (RFC 0159 §A.3 — opt-in)
|
|
|
109
110
|
if (!idpUrl || !scimUrl) return softSkip('inapplicable', 'opt-in: SAML IdP and/or SCIM endpoint not provided');
|
|
110
111
|
|
|
111
112
|
// 1. Provision a SCIM user carrying an opaque, IdP-stable externalId.
|
|
112
|
-
|
|
113
|
+
//
|
|
114
|
+
// MINTED PER EXERCISE (2.37.0). This was the literal `'idp-op-8f3a'`, and
|
|
115
|
+
// the SCIM directory it is provisioned into is the OPERATOR's
|
|
116
|
+
// (`OPENWOP_TEST_SCIM_URL`) — durable state outside this process. Step 3
|
|
117
|
+
// DEACTIVATES that user, and deactivation is the whole point of the leg, so
|
|
118
|
+
// the second run of this file against the same directory provisions an
|
|
119
|
+
// externalId that is already present and already deactivated. Step 2 then
|
|
120
|
+
// asserts "a valid linked SAML assertion authenticates before deactivation"
|
|
121
|
+
// against a subject the host is CORRECT to refuse: cold directory passes,
|
|
122
|
+
// warm directory fails, nothing about the host having changed. Same shape as
|
|
123
|
+
// the shared effect identity in `lib/effect-receiver.ts` (openwop#1513) and
|
|
124
|
+
// the fixed trigger-bridge dedup key.
|
|
125
|
+
//
|
|
126
|
+
// Unwitnessed: both opt-in variables are unset in every cut this suite has
|
|
127
|
+
// run, so the leg has never executed and the collision has never fired. It
|
|
128
|
+
// is read off the code, and the fix costs nothing if the reading is wrong.
|
|
129
|
+
const externalId = `idp-op-${randomBytes(6).toString('hex')}`;
|
|
113
130
|
const provision = await driver.post('/v1/host/sample/auth/scim/provision', {
|
|
114
131
|
scimUrl,
|
|
115
132
|
op: 'create-user',
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0212 — canonical JSON is RFC 8785 (JCS), and the input MUST be I-JSON.
|
|
3
|
+
*
|
|
4
|
+
* `conformance/vectors/jcs-v1.json` is normative: every signature and digest in
|
|
5
|
+
* the corpus (pack signatures, the certification-bundle attestation,
|
|
6
|
+
* `discovery.sha256`, `witnessSha256`, the RFC 0150 request digest) is over
|
|
7
|
+
* these bytes, and an SDK in another language reproduces the same file. This
|
|
8
|
+
* scenario holds the suite's one implementation (`lib/jcs.ts`) to it.
|
|
9
|
+
*
|
|
10
|
+
* Every leg RECOMPUTES from the vector input; none compares two constants from
|
|
11
|
+
* the file. The sabotages RFC 0212 names, each of which turns a leg red:
|
|
12
|
+
*
|
|
13
|
+
* - a rebuild-object canonicalizer (`JSON.stringify` of an object whose keys
|
|
14
|
+
* were inserted in sorted order) — integer-like keys enumerate first, so
|
|
15
|
+
* `integer-like-keys` fails;
|
|
16
|
+
* - NaN → `null` coercion — the value-boundary refusal leg fails;
|
|
17
|
+
* - a parser that keeps the last duplicate name (`JSON.parse`) — the
|
|
18
|
+
* `duplicate-name` refusal is accepted;
|
|
19
|
+
* - a `localeCompare` comparator — the ordering leg and the
|
|
20
|
+
* `rfc8785-3.2.3-sort` vector fail.
|
|
21
|
+
*
|
|
22
|
+
* Server-free and always-on; the vectors ship with the conformance package.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { dirname, resolve } from 'node:path';
|
|
30
|
+
import { canonicalJSON, canonicalizeText, codeUnitCompare, JcsRefusal } from '../lib/jcs.js';
|
|
31
|
+
import { witnessDigest, type BundleV3Requirement } from '../lib/certification-bundle-v3.js';
|
|
32
|
+
import { req } from '../lib/requirement-ids.js';
|
|
33
|
+
|
|
34
|
+
interface NumberVector { readonly ieee754: string; readonly canonical: string }
|
|
35
|
+
interface NumberRefusal { readonly ieee754: string; readonly refuse: string }
|
|
36
|
+
interface ObjectVector { readonly id: string; readonly why: string; readonly input: string; readonly canonical: string; readonly sha256: string }
|
|
37
|
+
interface RefusalVector { readonly id: string; readonly why: string; readonly input: string; readonly refuse: string }
|
|
38
|
+
interface Ordering { readonly why: string; readonly ids: readonly string[]; readonly codeUnit: readonly string[] }
|
|
39
|
+
|
|
40
|
+
const VECTORS_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'vectors', 'jcs-v1.json');
|
|
41
|
+
const doc = JSON.parse(readFileSync(VECTORS_PATH, 'utf8')) as {
|
|
42
|
+
numbers: readonly NumberVector[];
|
|
43
|
+
numberRefusals: readonly NumberRefusal[];
|
|
44
|
+
objects: readonly ObjectVector[];
|
|
45
|
+
refusals: readonly RefusalVector[];
|
|
46
|
+
ordering: Ordering;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const SPEC = 'RFC 0212 · spec/v2/core/conformance.md §"Canonical JSON"';
|
|
50
|
+
const sha256 = (s: string): string => createHash('sha256').update(s, 'utf8').digest('hex');
|
|
51
|
+
const double = (hex: string): number => Buffer.from(hex, 'hex').readDoubleBE(0);
|
|
52
|
+
|
|
53
|
+
function refusalOf(fn: () => unknown): JcsRefusal | undefined {
|
|
54
|
+
try { fn(); } catch (e) { if (e instanceof JcsRefusal) return e; throw e; }
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('RFC 0212 — canonical JSON is RFC 8785 JCS over I-JSON', () => {
|
|
59
|
+
it('the vector set is present and non-trivial', () => {
|
|
60
|
+
// An empty or truncated file would make every leg below vacuous.
|
|
61
|
+
expect(doc.numbers.length, req('openwop.it.jcs-vectors.the-vector-set-is-present-and-non-trivial', SPEC, 'the vectors MUST cover RFC 8785 Appendix B numbers, documents and every §B refusal kind')).toBeGreaterThanOrEqual(20);
|
|
62
|
+
expect(doc.objects.length).toBeGreaterThanOrEqual(10);
|
|
63
|
+
expect(new Set(doc.refusals.map((r) => r.refuse))).toEqual(new Set(['duplicate-name', 'lone-surrogate', 'integer-out-of-range', 'not-json', 'non-finite']));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it.each(doc.numbers.map((n) => [n.ieee754, n] as const))('number %s serializes per RFC 8785 Appendix B', (_hex, n) => {
|
|
67
|
+
expect(canonicalJSON(double(n.ieee754)), req('openwop.it.jcs-vectors.number-s-serializes-per-rfc-8785-appendix-b', SPEC, 'numbers MUST serialize as ECMAScript Number::toString (RFC 8785 §3.2.2.3)')).toBe(n.canonical);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it.each(doc.objects.map((o) => [o.id, o] as const))('document %s canonicalizes and hashes', (_id, o) => {
|
|
71
|
+
// The preimage is asserted as well as the hash so a failing implementation
|
|
72
|
+
// can diff WHICH member it got wrong.
|
|
73
|
+
const canonical = canonicalizeText(o.input);
|
|
74
|
+
expect(canonical, req('openwop.it.jcs-vectors.document-s-canonicalizes-and-hashes', SPEC, o.why)).toBe(o.canonical);
|
|
75
|
+
expect(sha256(canonical), req('openwop.it.jcs-vectors.document-s-canonicalizes-and-hashes', SPEC, o.why)).toBe(o.sha256);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it.each(doc.refusals.map((r) => [r.id, r] as const))('text %s is refused, not coerced', (_id, r) => {
|
|
79
|
+
const refusal = refusalOf(() => canonicalizeText(r.input));
|
|
80
|
+
expect(refusal?.kind, req('openwop.it.jcs-vectors.text-s-is-refused-not-coerced', SPEC, `RFC 0212 §B: ${r.why}`)).toBe(r.refuse);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('a non-I-JSON native value is refused at the value boundary', () => {
|
|
84
|
+
const cases: readonly [string, unknown][] = [
|
|
85
|
+
...doc.numberRefusals.map((n) => [`ieee754 ${n.ieee754}`, { n: double(n.ieee754) }] as [string, unknown]),
|
|
86
|
+
['NaN', { n: Number.NaN }],
|
|
87
|
+
['undefined member', { a: undefined }],
|
|
88
|
+
['undefined element', [undefined]],
|
|
89
|
+
['Date', { at: new Date(0) }],
|
|
90
|
+
['bigint', { n: 1n }],
|
|
91
|
+
['lone surrogate', { s: '\ud800' }],
|
|
92
|
+
['lone surrogate in a name', { '\udc00': 1 }],
|
|
93
|
+
];
|
|
94
|
+
for (const [label, value] of cases) {
|
|
95
|
+
expect(refusalOf(() => canonicalJSON(value)), req('openwop.it.jcs-vectors.a-non-i-json-native-value-is-refused-at-the-value-boundary', SPEC, `RFC 0212 §B: ${label} MUST be refused, not serialized`)).toBeInstanceOf(JcsRefusal);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('member names and certification rows sort by UTF-16 code units, not by locale', () => {
|
|
100
|
+
const o = doc.ordering;
|
|
101
|
+
expect([...o.ids].sort(codeUnitCompare), req('openwop.it.jcs-vectors.member-names-and-certification-rows-sort-by-utf-16-code-units-not-by-locale', SPEC, o.why)).toEqual([...o.codeUnit]);
|
|
102
|
+
// The certification digest uses the same comparator (RFC 0212 §C): rows
|
|
103
|
+
// given in vector order digest to the JCS of the code-unit-sorted rows.
|
|
104
|
+
const rows: BundleV3Requirement[] = o.ids.map((id) => ({ id, scenario: 's.test.ts', result: 'executed-pass' }) as BundleV3Requirement);
|
|
105
|
+
const expected = sha256(canonicalJSON([...o.codeUnit].map((id) => ({ id, scenario: 's.test.ts', result: 'executed-pass' }))));
|
|
106
|
+
expect(witnessDigest(rows), req('openwop.it.jcs-vectors.member-names-and-certification-rows-sort-by-utf-16-code-units-not-by-locale', SPEC, 'witnessSha256 rows MUST sort by id in UTF-16 code-unit order; a locale-sensitive comparator MUST NOT be used')).toBe(expected);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -110,6 +110,14 @@ describe('RFC 0150 §C — semantic request digest golden vectors', () => {
|
|
|
110
110
|
expect(digest('tools-sorted-by-name'), req('openwop.it.semantic-digest-vectors.tool-order-is-not-semantic-sorted-and-reversed-agree', 'RFC 0150 §C', 'tool order is not semantic — sorted and reversed agree')).toBe(digest('tools-reversed-same-digest'));
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
it('tool order uses UTF-16 code units, where a locale comparator disagrees', () => {
|
|
114
|
+
// RFC 0212 §D. `alpha`/`zeta` order the same under every comparator, so the
|
|
115
|
+
// pair above cannot catch a `localeCompare` sort; `getWeather`/`get_weather`
|
|
116
|
+
// can — English collation ignores `_` and puts them the other way round.
|
|
117
|
+
expect(digest('tools-code-unit-order'), req('openwop.it.semantic-digest-vectors.tool-order-uses-utf-16-code-units-where-a-locale-comparator-disagrees', 'RFC 0212 §D · RFC 0150 §C', 'tools[] MUST sort by name in UTF-16 code-unit order')).toBe(digest('tools-code-unit-order-reversed'));
|
|
118
|
+
expect(canonicalize(projectSemanticRequestV2(byId.get('tools-code-unit-order')?.input ?? {})), req('openwop.it.semantic-digest-vectors.tool-order-uses-utf-16-code-units-where-a-locale-comparator-disagrees', 'RFC 0212 §D · RFC 0150 §C', 'getWeather (0x57) MUST precede get_weather (0x5F)')).toContain('"tools":[{"name":"getWeather"');
|
|
119
|
+
});
|
|
120
|
+
|
|
113
121
|
it('message order IS semantic — reversing changes the digest', () => {
|
|
114
122
|
expect(
|
|
115
123
|
digest('message-order-is-semantic'),
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
} from '../lib/triggerBridge.js';
|
|
39
39
|
import { queryTestEvents, requireEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
|
|
40
40
|
import { req } from '../lib/requirement-ids.js';
|
|
41
|
+
import { freshDedupKey } from '../lib/triggerBridge.js';
|
|
41
42
|
|
|
42
43
|
const CONTENT_FREE_FORBIDDEN = ['body', 'headers', 'payload', 'secret', 'credentials', 'token', 'apiKey'];
|
|
43
44
|
|
|
@@ -56,7 +57,21 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
|
|
|
56
57
|
if (!(await isEventLogSeamAvailable())) return seamAbsent('host advertises openwop-trigger-bridge but the event-log seam is absent');
|
|
57
58
|
|
|
58
59
|
// ---- Leg 1: dedup → effectively-once (§C-1) ---------------------------
|
|
59
|
-
|
|
60
|
+
//
|
|
61
|
+
// The dedupKey is MINTED PER EXERCISE, and that is load-bearing (2.37.0).
|
|
62
|
+
// It used to be the literal `'conformance-dedup-key'`, and §C-1's dedup
|
|
63
|
+
// window is a ≥24h FLOOR — so the second run of this file against the same
|
|
64
|
+
// host, any time that day, hands the bridge a key it has already delivered.
|
|
65
|
+
// A CONFORMANT host then collapses the whole exercise into the first run's
|
|
66
|
+
// outcome and emits ZERO `delivered` attempts under this run's id, and the
|
|
67
|
+
// `=== 1` below convicts it. Cold host passes, warm host fails, nothing
|
|
68
|
+
// about the host having changed — the same shape as the RFC 0158 row that
|
|
69
|
+
// shared one effect identity between two files (`lib/effect-receiver.ts`).
|
|
70
|
+
// The REPETITION that §C-1 is about happens INSIDE `driveDelivery`'s
|
|
71
|
+
// `scenario: 'dedup'`, so a fresh key per exercise removes the cross-run
|
|
72
|
+
// collision without weakening the clause.
|
|
73
|
+
const dedupKey = freshDedupKey('queue');
|
|
74
|
+
const dedup = await driveDelivery({ scenario: 'dedup', dedupKey, source: 'queue' });
|
|
60
75
|
if (dedup === null) return seamAbsent('host advertises openwop-trigger-bridge but the delivery seam is unwired');
|
|
61
76
|
|
|
62
77
|
// The profile is derived AND the seam is wired — missing evidence is a
|
|
@@ -68,7 +83,7 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
|
|
|
68
83
|
'trigger.delivery.attempted (dedup)',
|
|
69
84
|
);
|
|
70
85
|
const deliveredForKey = dedupEvents.filter(
|
|
71
|
-
(e) => e.payload.dedupKey ===
|
|
86
|
+
(e) => e.payload.dedupKey === dedupKey && e.payload.outcome === 'delivered',
|
|
72
87
|
);
|
|
73
88
|
expect(
|
|
74
89
|
deliveredForKey.length === 1,
|
|
@@ -47,7 +47,7 @@ import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
|
|
|
47
47
|
import { driver } from '../lib/driver.js';
|
|
48
48
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
49
49
|
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
50
|
-
import { driveDelivery } from '../lib/triggerBridge.js';
|
|
50
|
+
import { driveDelivery, freshStreamDedupKey } from '../lib/triggerBridge.js';
|
|
51
51
|
import { req } from '../lib/requirement-ids.js';
|
|
52
52
|
import { softSkip } from '../lib/soft-skip.js';
|
|
53
53
|
|
|
@@ -203,7 +203,22 @@ describe.skipIf(HTTP_SKIP)('trigger-stream-cdc: behavioral ingestion + dedup (ca
|
|
|
203
203
|
const external = tb?.ingestion?.externalSources ?? [];
|
|
204
204
|
if (!behaviorGate('triggerBridge.ingestion', (external.length ?? 0) > 0)) return;
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
// The broker coordinates are MINTED PER EXERCISE (2.37.x), and that is
|
|
207
|
+
// load-bearing. This was the literal `'events:3:99001'`, and §F.5 reuses
|
|
208
|
+
// §C-1's dedup window — a ≥24h FLOOR — verbatim, so the second run of this
|
|
209
|
+
// file against the same host, any time that day, hands the bridge an offset
|
|
210
|
+
// it has already delivered. A CONFORMANT host then collapses the exercise
|
|
211
|
+
// into the first run's outcome and reports neither a `deliveredCount` of 1
|
|
212
|
+
// nor `outcome: 'delivered'` under this run, and the assertion below
|
|
213
|
+
// convicts it: cold host passes, warm host fails, nothing about the host
|
|
214
|
+
// having changed. The repetition §C-1 is about happens INSIDE the seam's
|
|
215
|
+
// `scenario: 'dedup'` (it delivers the key TWICE), so a fresh offset per
|
|
216
|
+
// exercise removes the cross-run collision without weakening the clause.
|
|
217
|
+
// Same defect and same fix as `trigger-bridge-delivery`'s dedup key; the
|
|
218
|
+
// offset rather than an opaque token, because the `(topic,partition,offset)`
|
|
219
|
+
// keying is what the `req()` message below asserts over.
|
|
220
|
+
const dedupKey = freshStreamDedupKey();
|
|
221
|
+
const first = await driveDelivery({ scenario: 'dedup', dedupKey, source: 'stream' });
|
|
207
222
|
if (first === null) return softSkip('blocked', 'precondition not met — `first === null` returned early (delivery seam unwired — soft-skip) (seam, prior step, or fixture unavailable)'); // delivery seam unwired — soft-skip
|
|
208
223
|
if (first.outcome === undefined && !external.includes('stream')) return softSkip('blocked', 'precondition not met — `first.outcome === undefined && !external.includes(\'stream\')` returned early (pre-0127 host — soft-skip) (seam, prior step, or fixture unavailable)'); // pre-0127 host — soft-skip
|
|
209
224
|
expect(
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* @see spec/v2/interop-map.json a2a.operations / a2a.taskState / a2a.errors
|
|
26
26
|
* @see RFCS/0208-v2-a2a-mcp-operation-mappings.md §C, §D, §E
|
|
27
27
|
* @see RFCS/0199-outbound-oauth-client-and-credential-interrupt.md §D.1 (the credential leg; interop-map.json a2a.taskState override row)
|
|
28
|
+
* @see RFCS/0214-a2a-push-credential-is-a-destination-credential.md (the push-config refusal leg; invariants a2a-push-credential-destination-bound, a2a-push-secrets-not-returned)
|
|
28
29
|
* @see RFCS/0211-a2a-error-details-are-errorinfo.md §A–§E (the error-details legs; the isolation comparator)
|
|
29
30
|
*/
|
|
30
31
|
import { describe, it, expect } from 'vitest';
|
|
@@ -249,6 +250,33 @@ describe('RFC 0208 — v2-a2a-operation-map (host as A2A 1.0 server, gated on a2
|
|
|
249
250
|
await rpc(t.url, 'CancelTask', { id });
|
|
250
251
|
});
|
|
251
252
|
|
|
253
|
+
it('a host not advertising a2a.pushNotifications refuses all four push-config operations with PushNotificationNotSupportedError', async () => {
|
|
254
|
+
const rid = 'openwop.requirement.0214.a2a-push-unadvertised-refused';
|
|
255
|
+
const pushDoc = 'interop-map.json a2a.errors PushNotificationNotSupportedError (serverWhen: push config without a2a.pushNotifications) and the push-config operation rows; A2A v1.0.1 §3.3.4';
|
|
256
|
+
const t = await target(true);
|
|
257
|
+
if (!t.ok) return skip(t, rid);
|
|
258
|
+
const a2a = (await familyAdvertised('a2a'))!;
|
|
259
|
+
if (a2a['pushNotifications'] === true) return softSkip('inapplicable', 'a2a.pushNotifications is advertised — the push-config rows are served here, not refused');
|
|
260
|
+
const first = await startApprovalTask(t.url);
|
|
261
|
+
const id = first.task?.id;
|
|
262
|
+
expect(typeof id, req(rid, DOC, `SendMessage MUST start a task: ${JSON.stringify(first.rpc.error)}`)).toBe('string');
|
|
263
|
+
// Positive control: the task is readable, so a -32001 below could not be masking the refusal under test.
|
|
264
|
+
const read = await rpc(t.url, 'GetTask', { id });
|
|
265
|
+
expect(read.error, req(rid, 'a2a.operations GetTask', `positive control: the task the suite just started MUST be readable (got ${JSON.stringify(read.error)})`)).toBeUndefined();
|
|
266
|
+
const configId = `conf-0214-${randomBytes(6).toString('hex')}`;
|
|
267
|
+
const calls: Array<[string, Record<string, unknown>]> = [
|
|
268
|
+
['CreateTaskPushNotificationConfig', { taskId: id, url: 'https://push.example.com/openwop-conformance' }],
|
|
269
|
+
['GetTaskPushNotificationConfig', { taskId: id, id: configId }],
|
|
270
|
+
['ListTaskPushNotificationConfigs', { taskId: id }],
|
|
271
|
+
['DeleteTaskPushNotificationConfig', { taskId: id, id: configId }],
|
|
272
|
+
];
|
|
273
|
+
for (const [method, params] of calls) {
|
|
274
|
+
const r = await rpc(t.url, method, params);
|
|
275
|
+
expect(r.error?.code, req(rid, pushDoc, `${method} on a host not advertising a2a.pushNotifications MUST be refused PushNotificationNotSupportedError -32003 — not method-not-found -32601 (got ${JSON.stringify(r.error ?? r.result)})`)).toBe(-32003);
|
|
276
|
+
}
|
|
277
|
+
await rpc(t.url, 'CancelTask', { id });
|
|
278
|
+
});
|
|
279
|
+
|
|
252
280
|
it('ListTasks returns only what listRuns returns to the same Subject; tenant and contextId never select', async () => {
|
|
253
281
|
const other = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
|
|
254
282
|
if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-caller leg cannot run');
|
|
@@ -44,6 +44,7 @@ const CATALOG = 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json
|
|
|
44
44
|
const clone = <T>(o: T): T => JSON.parse(JSON.stringify(o)) as T;
|
|
45
45
|
const POSITIVE = JSON.parse(readFileSync(join(FIXTURES_DIR, 'a2ui-v09', 'positive-approve-brief.json'), 'utf8')) as Json;
|
|
46
46
|
const V1_SURFACE: Json = { catalogVersion: '0.9.1', surface: { title: 'Kickoff', components: [{ component: 'heading', text: 'Kickoff', level: 2 }, { component: 'action.button', id: 'go', label: 'Go', action: { target: 'resume' } }] } };
|
|
47
|
+
const V1_LATER: Json = { catalogVersion: '0.9.1', surface: { title: 'Kickoff, later', components: [{ component: 'heading', text: 'Kickoff, later', level: 2 }] } };
|
|
47
48
|
|
|
48
49
|
let seq = 0;
|
|
49
50
|
const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -217,8 +218,16 @@ describe('RFC 0209 §C.11 — recorded surfaces are returned as recorded (seam-g
|
|
|
217
218
|
const sequence = (r.json as { sequence: number }).sequence;
|
|
218
219
|
const events = await eventsOf(runId);
|
|
219
220
|
expect(carries(events, payload), req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', 'the run\'s poll MUST return the recorded surface payload byte-equal — no surface is regenerated')).toBe(true);
|
|
220
|
-
|
|
221
|
-
|
|
221
|
+
// The surface is the last event of the suspended run, so `sequence + 1`
|
|
222
|
+
// names no event and runs.md §Fork REQUIRES 422 fork_point_invalid for it.
|
|
223
|
+
// Record one more envelope on the same surface and fork AT that event:
|
|
224
|
+
// the surface (sequence < fromSeq) is fixed history, the later one re-executes.
|
|
225
|
+
const later = await emit(runId, envelope(2, v2(sid('rec'), [dataMsg()])));
|
|
226
|
+
expect(later.status, req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.9', `an updateDataModel on the live surface MUST be admitted (it supplies the fork point), got ${code(later)}`)).toBe(201);
|
|
227
|
+
const fromSeq = (later.json as { sequence: number }).sequence;
|
|
228
|
+
expect(fromSeq > sequence, req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'api/seams-v2.yaml emitA2uiSurface', `a later recording names a later sequence (surface ${sequence}, update ${fromSeq})`)).toBe(true);
|
|
229
|
+
const fork = await driver.post(`/runs/${encodeURIComponent(runId)}:fork`, { mode: 'replay', fromSeq });
|
|
230
|
+
expect(fork.status, req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', `POST :fork at fromSeq ${fromSeq} (a recorded event after the surface) MUST answer 201, got ${code(fork)}`)).toBe(201);
|
|
222
231
|
const forkId = (fork.json as { runId: string }).runId;
|
|
223
232
|
expect(carries(await eventsOf(forkId), payload), req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', 'a fork at a fromSeq after the surface MUST carry it unchanged')).toBe(true);
|
|
224
233
|
await done(forkId);
|
|
@@ -234,8 +243,13 @@ describe('RFC 0209 §C.11 — recorded surfaces are returned as recorded (seam-g
|
|
|
234
243
|
expect(r.status, req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', `a version-1 surface at floor 1 MUST be admitted, got ${code(r)}`)).toBe(201);
|
|
235
244
|
const sequence = (r.json as { sequence: number }).sequence;
|
|
236
245
|
expect(carries(await eventsOf(runId), V1_SURFACE), req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', 'the poll MUST return the version-1 surface byte-equal')).toBe(true);
|
|
237
|
-
|
|
238
|
-
|
|
246
|
+
// As in the version-2 leg: fork at a recorded event after the surface, never at `sequence + 1` (no such event; 422 fork_point_invalid).
|
|
247
|
+
const later = await emit(runId, envelope(1, V1_LATER));
|
|
248
|
+
expect(later.status, req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', `a second version-1 surface at floor 1 MUST be admitted (it supplies the fork point), got ${code(later)}`)).toBe(201);
|
|
249
|
+
const fromSeq = (later.json as { sequence: number }).sequence;
|
|
250
|
+
expect(fromSeq > sequence, req('openwop.requirement.0209.legacy-readable', 'api/seams-v2.yaml emitA2uiSurface', `a later recording names a later sequence (surface ${sequence}, next ${fromSeq})`)).toBe(true);
|
|
251
|
+
const fork = await driver.post(`/runs/${encodeURIComponent(runId)}:fork`, { mode: 'replay', fromSeq });
|
|
252
|
+
expect(fork.status, req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', `POST :fork at fromSeq ${fromSeq} (a recorded event after the surface) MUST answer 201, got ${code(fork)}`)).toBe(201);
|
|
239
253
|
const forkId = (fork.json as { runId: string }).runId;
|
|
240
254
|
expect(carries(await eventsOf(forkId), V1_SURFACE), req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', 'a fork at a later fromSeq MUST carry the version-1 surface unchanged')).toBe(true);
|
|
241
255
|
await done(forkId);
|