@kontourai/survey 3.0.0 → 5.0.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/README.md +4 -0
- package/dist/examples/calibrated-auto-accept.d.ts +22 -15
- package/dist/examples/calibrated-auto-accept.js +40 -36
- package/dist/examples/review-workbench/server-apply-consumer.js +5 -0
- package/dist/src/calibration.d.ts +48 -21
- package/dist/src/calibration.js +72 -33
- package/dist/src/canonical-reviewed-trust-input.js +73 -34
- package/dist/src/console/review-console-server.d.ts +3 -1
- package/dist/src/console/review-console-server.js +203 -50
- package/dist/src/extraction-envelope.d.ts +81 -3
- package/dist/src/extraction-envelope.js +183 -24
- package/dist/src/index.d.ts +9 -8
- package/dist/src/index.js +3 -3
- package/dist/src/inquiry-mapping.d.ts +15 -1
- package/dist/src/inquiry-mapping.js +10 -2
- package/dist/src/mcp/review-mcp.js +112 -90
- package/dist/src/producer-profile.d.ts +41 -2
- package/dist/src/producer-profile.js +29 -2
- package/dist/src/review-session-file.d.ts +64 -0
- package/dist/src/review-session-file.js +320 -0
- package/dist/src/review-workbench/edited-value.d.ts +70 -0
- package/dist/src/review-workbench/edited-value.js +147 -0
- package/dist/src/review-workbench/extraction-inspector.d.ts +15 -1
- package/dist/src/review-workbench/extraction-inspector.js +55 -19
- package/dist/src/review-workbench/queue-binding.js +1 -1
- package/dist/src/review-workbench/review-presentation.d.ts +46 -1
- package/dist/src/review-workbench/review-presentation.js +72 -1
- package/dist/src/review-workbench/review-queue-session.d.ts +19 -5
- package/dist/src/review-workbench/review-queue-session.js +54 -14
- package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
- package/dist/src/review-workbench/review-session-replay.js +82 -3
- package/dist/src/review-workbench/review-workbench-css.generated.js +2 -0
- package/dist/src/review-workbench/review-workbench.css +2 -0
- package/dist/src/review-workbench/review-workbench.d.ts +22 -11
- package/dist/src/review-workbench/review-workbench.js +91 -26
- package/dist/src/review-workbench/review-workbench.standalone.css +2 -0
- package/dist/src/review-workbench/server-review-session.d.ts +3 -1
- package/dist/src/review-workbench/server-review-session.js +1 -0
- package/dist/src/reviewed-candidate-resolution.js +13 -7
- package/dist/src/schema-mapping.d.ts +23 -0
- package/dist/src/schema-mapping.js +30 -20
- package/dist/src/surface-reviewed-extraction.js +4 -0
- package/dist/src/to-surface.d.ts +30 -6
- package/dist/src/to-surface.js +312 -18
- package/dist/src/types.d.ts +44 -1
- package/package.json +5 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
|
|
2
2
|
import { canonicalJson } from "./review-workbench/canonical.js";
|
|
3
|
-
import { workbenchDecisionDefinitions } from "./review-workbench/review-queue-session.js";
|
|
3
|
+
import { decisionSelectsNoCandidate, workbenchDecisionDefinitions } from "./review-workbench/review-queue-session.js";
|
|
4
4
|
/**
|
|
5
5
|
* Projects server-applied review records into the complete SurveyInput consumed
|
|
6
6
|
* by buildSurveyTrustBundle. The ReviewItem and ReviewWorkbenchResult are the
|
|
@@ -33,15 +33,24 @@ export function buildCanonicalReviewedTrustInput(options) {
|
|
|
33
33
|
throw new Error(`ReviewItem ${item.metadata.name} has no canonical server-applied result.`);
|
|
34
34
|
}
|
|
35
35
|
assertCanonicalResult(item, result);
|
|
36
|
+
// Reject-all or could-not-confirm on a conflict selects no candidate: the
|
|
37
|
+
// set, the review outcome and the claim then name none of its values.
|
|
38
|
+
const selectsNone = decisionSelectsNoCandidate(item, result.decision);
|
|
39
|
+
const rejectsAll = selectsNone && result.decision === "reject-proposed";
|
|
40
|
+
const rejectedRole = workbenchDecisionDefinitions[result.decision].candidateRole;
|
|
41
|
+
const rejectAllReason = result.rationale?.trim() || "Every proposed value for this claim was rejected.";
|
|
36
42
|
const candidates = item.spec.candidates.map((candidate) => {
|
|
37
|
-
const records = projectCandidate(item, candidate
|
|
43
|
+
const records = projectCandidate(item, rejectsAll && candidate.role === rejectedRole
|
|
44
|
+
? { ...candidate, rejectionReason: candidate.rejectionReason ?? rejectAllReason }
|
|
45
|
+
: candidate);
|
|
38
46
|
addConsistent(rawSources, records.rawSource, "raw source");
|
|
39
47
|
addConsistent(extractions, records.extraction, "extraction");
|
|
40
48
|
return records.candidate;
|
|
41
49
|
});
|
|
42
|
-
const selected = item.spec.candidates.find((candidate) => candidate.id === result.selectedCandidateId);
|
|
43
|
-
const selectedRecordId = selected.projection?.candidateId ?? selected.id;
|
|
44
|
-
|
|
50
|
+
const selected = selectsNone ? undefined : item.spec.candidates.find((candidate) => candidate.id === result.selectedCandidateId);
|
|
51
|
+
const selectedRecordId = selected ? selected.projection?.candidateId ?? selected.id : undefined;
|
|
52
|
+
// Every candidate carries the same candidate-set id (checked below).
|
|
53
|
+
const candidateSetId = (selected ?? item.spec.candidates[0])?.projection?.candidateSetId
|
|
45
54
|
?? item.spec.projection?.candidateSetId
|
|
46
55
|
?? `${item.metadata.name}.candidates`;
|
|
47
56
|
if (candidates.some((candidate) => candidate.metadata?.candidateSetId !== candidateSetId)) {
|
|
@@ -56,10 +65,10 @@ export function buildCanonicalReviewedTrustInput(options) {
|
|
|
56
65
|
? { metadata: Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== "candidateSetId")) }
|
|
57
66
|
: {}),
|
|
58
67
|
})),
|
|
59
|
-
selectedCandidateId: selectedRecordId,
|
|
68
|
+
...(selectedRecordId !== undefined ? { selectedCandidateId: selectedRecordId } : {}),
|
|
60
69
|
status: result.decision === "could-not-confirm"
|
|
61
70
|
? (item.spec.candidateSetStatus ?? "needs-review")
|
|
62
|
-
: "resolved",
|
|
71
|
+
: rejectsAll ? "rejected" : "resolved",
|
|
63
72
|
...(result.rationale ?? item.spec.rationale
|
|
64
73
|
? { rationale: result.rationale ?? item.spec.rationale }
|
|
65
74
|
: {}),
|
|
@@ -67,12 +76,12 @@ export function buildCanonicalReviewedTrustInput(options) {
|
|
|
67
76
|
addConsistent(candidateSets, candidateSet, "candidate set");
|
|
68
77
|
const decision = result.reviewDecision.spec;
|
|
69
78
|
const reviewOutcomeId = decision.projection?.reviewOutcomeId
|
|
70
|
-
?? selected.projection?.reviewOutcomeId
|
|
79
|
+
?? (selected ?? item.spec).projection?.reviewOutcomeId
|
|
71
80
|
?? `${item.metadata.name}.${result.decision}.review-outcome`;
|
|
72
81
|
const reviewOutcome = {
|
|
73
82
|
id: reviewOutcomeId,
|
|
74
83
|
candidateSetId,
|
|
75
|
-
candidateId: selectedRecordId,
|
|
84
|
+
...(selectedRecordId !== undefined ? { candidateId: selectedRecordId } : {}),
|
|
76
85
|
status: result.status,
|
|
77
86
|
...(decision.resolution ? { resolution: decision.resolution } : {}),
|
|
78
87
|
...(decision.resolutionReason ? { resolutionReason: decision.resolutionReason } : {}),
|
|
@@ -90,34 +99,42 @@ export function buildCanonicalReviewedTrustInput(options) {
|
|
|
90
99
|
},
|
|
91
100
|
};
|
|
92
101
|
addConsistent(reviewOutcomes, reviewOutcome, "review outcome");
|
|
93
|
-
|
|
102
|
+
// Every candidate names the same claim target (checked by assertCanonicalResult).
|
|
103
|
+
const hint = (selected ?? item.spec.candidates[0]).claimTarget;
|
|
94
104
|
assertSingleProjectionId("claim", item.metadata.name, [
|
|
95
105
|
decision.projection?.claimId,
|
|
96
106
|
item.spec.projection?.claimId,
|
|
97
107
|
...item.spec.candidates.flatMap((candidate) => [candidate.projection?.claimId, candidate.claimTarget.claimId]),
|
|
98
108
|
]);
|
|
99
109
|
const claimId = decision.projection?.claimId
|
|
100
|
-
?? selected
|
|
110
|
+
?? selected?.projection?.claimId
|
|
101
111
|
?? item.spec.projection?.claimId
|
|
102
112
|
?? hint.claimId
|
|
103
113
|
?? `${item.metadata.name}.claim`;
|
|
104
114
|
const claim = {
|
|
105
115
|
id: claimId,
|
|
106
116
|
candidateSetId,
|
|
107
|
-
candidateId: selectedRecordId,
|
|
117
|
+
...(selectedRecordId !== undefined ? { candidateId: selectedRecordId } : {}),
|
|
108
118
|
subjectType: hint.subjectType,
|
|
109
119
|
subjectId: hint.subjectId,
|
|
110
120
|
facet: hint.facet,
|
|
111
121
|
claimType: hint.claimType,
|
|
112
122
|
fieldOrBehavior: hint.fieldOrBehavior,
|
|
113
|
-
value
|
|
114
|
-
|
|
123
|
+
// Surface requires a claim value; a claim that selects none of its
|
|
124
|
+
// candidates carries null, and buildSurveyTrustBundle lists every value.
|
|
125
|
+
value: selectsNone ? null : result.effectiveValue,
|
|
126
|
+
// Could-not-confirm keeps the pre-review posture, and a conflicting or
|
|
127
|
+
// escalated candidate set is disputed before any review (see
|
|
128
|
+
// docs/decisions/could-not-confirm.md), never merely proposed.
|
|
129
|
+
status: result.decision === "could-not-confirm" && (candidateSet.status === "conflict" || candidateSet.status === "escalated")
|
|
130
|
+
? "disputed"
|
|
131
|
+
: result.status,
|
|
115
132
|
impactLevel: hint.impactLevel,
|
|
116
133
|
updatedAt: decision.reviewedAt ?? options.generatedAt,
|
|
117
134
|
...(hint.evidenceType ? { evidenceType: hint.evidenceType } : {}),
|
|
118
135
|
...(hint.evidenceMethod ? { evidenceMethod: hint.evidenceMethod } : {}),
|
|
119
136
|
...(hint.derivedFrom ? { derivedFrom: [...hint.derivedFrom] } : {}),
|
|
120
|
-
collectedBy: hint.collectedBy ?? selected.
|
|
137
|
+
collectedBy: hint.collectedBy ?? sharedExtractor(selected ? [selected] : item.spec.candidates) ?? options.source,
|
|
121
138
|
...(decision.actor?.id ? { actor: decision.actor.id } : {}),
|
|
122
139
|
};
|
|
123
140
|
addConsistent(claims, claim, "claim target");
|
|
@@ -137,24 +154,38 @@ export function buildCanonicalReviewedTrustInput(options) {
|
|
|
137
154
|
};
|
|
138
155
|
}
|
|
139
156
|
function assertCanonicalResult(item, result) {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
const selectsNone = decisionSelectsNoCandidate(item, result.decision);
|
|
158
|
+
let selected;
|
|
159
|
+
if (selectsNone) {
|
|
160
|
+
if (result.selectedCandidate !== undefined || result.selectedCandidateId !== undefined || result.selectedCandidateRole !== undefined
|
|
161
|
+
|| result.selectedValue !== undefined || result.selectedDisplayValue !== undefined
|
|
162
|
+
|| result.effectiveValue !== undefined || result.effectiveDisplayValue !== undefined || result.editedValue !== undefined) {
|
|
163
|
+
throw new Error(`Review result ${result.reviewItemName} names a selected value, but its ${result.decision} decision selects no candidate.`);
|
|
164
|
+
}
|
|
165
|
+
if (canonicalJson(item.spec.candidates) !== canonicalJson(result.unselectedCandidates)) {
|
|
166
|
+
throw new Error(`Review result ${result.reviewItemName} unselected candidates do not match its canonical ReviewItem.`);
|
|
167
|
+
}
|
|
150
168
|
}
|
|
151
|
-
|
|
152
|
-
|
|
169
|
+
else {
|
|
170
|
+
selected = item.spec.candidates.find((candidate) => candidate.id === result.selectedCandidateId);
|
|
171
|
+
if (!selected) {
|
|
172
|
+
throw new Error(`Review result ${result.reviewItemName} selects an unknown candidate.`);
|
|
173
|
+
}
|
|
174
|
+
if (canonicalJson(selected) !== canonicalJson(result.selectedCandidate)) {
|
|
175
|
+
throw new Error(`Review result ${result.reviewItemName} selected candidate does not match its canonical ReviewItem.`);
|
|
176
|
+
}
|
|
177
|
+
const unselected = item.spec.candidates.filter((candidate) => candidate.id !== selected.id);
|
|
178
|
+
if (canonicalJson(unselected) !== canonicalJson(result.unselectedCandidates)) {
|
|
179
|
+
throw new Error(`Review result ${result.reviewItemName} unselected candidates do not match its canonical ReviewItem.`);
|
|
180
|
+
}
|
|
181
|
+
if (result.selectedCandidateRole !== selected.role || canonicalJson(result.selectedValue) !== canonicalJson(selected.value)) {
|
|
182
|
+
throw new Error(`Review result ${result.reviewItemName} selected identity does not match its canonical ReviewItem.`);
|
|
183
|
+
}
|
|
153
184
|
}
|
|
154
185
|
const decision = result.reviewDecision.spec;
|
|
155
186
|
const definition = workbenchDecisionDefinitions[result.decision];
|
|
156
187
|
if (decision.reviewItemName !== item.metadata.name
|
|
157
|
-
|| decision.candidateId !==
|
|
188
|
+
|| decision.candidateId !== selected?.id
|
|
158
189
|
|| decision.status !== result.status
|
|
159
190
|
|| decision.status !== definition.status
|
|
160
191
|
|| decision.rationale !== result.rationale
|
|
@@ -164,14 +195,17 @@ function assertCanonicalResult(item, result) {
|
|
|
164
195
|
if ((result.decision === "could-not-confirm") !== (decision.resolution === "could_not_confirm")) {
|
|
165
196
|
throw new Error(`Review result ${result.reviewItemName} contradicts its canonical review resolution.`);
|
|
166
197
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
198
|
+
if (selected) {
|
|
199
|
+
const expectedEffective = result.editedValue !== undefined && result.decision === "accept-proposed"
|
|
200
|
+
? result.editedValue
|
|
201
|
+
: selected.value;
|
|
202
|
+
if (canonicalJson(expectedEffective) !== canonicalJson(result.effectiveValue)) {
|
|
203
|
+
throw new Error(`Review result ${result.reviewItemName} effective value is not canonical.`);
|
|
204
|
+
}
|
|
172
205
|
}
|
|
206
|
+
const reference = selected ?? item.spec.candidates[0];
|
|
173
207
|
for (const candidate of item.spec.candidates) {
|
|
174
|
-
if (canonicalJson(claimTargetIdentity(candidate.claimTarget)) !== canonicalJson(claimTargetIdentity(
|
|
208
|
+
if (canonicalJson(claimTargetIdentity(candidate.claimTarget)) !== canonicalJson(claimTargetIdentity(reference.claimTarget))) {
|
|
175
209
|
throw new Error(`ReviewItem ${item.metadata.name} candidates carry conflicting claim targets.`);
|
|
176
210
|
}
|
|
177
211
|
}
|
|
@@ -263,3 +297,8 @@ function assertSingleProjectionId(label, itemName, values) {
|
|
|
263
297
|
throw new Error(`ReviewItem ${itemName} carries conflicting ${label} projection ids.`);
|
|
264
298
|
}
|
|
265
299
|
}
|
|
300
|
+
/** The extractor every candidate shares, or undefined when they differ. */
|
|
301
|
+
function sharedExtractor(candidates) {
|
|
302
|
+
const extractors = new Set(candidates.map((candidate) => candidate.extraction.extractor));
|
|
303
|
+
return extractors.size === 1 ? [...extractors][0] : undefined;
|
|
304
|
+
}
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* Routes:
|
|
6
6
|
* GET / HTML shell that mounts the workbench
|
|
7
7
|
* GET /api/session Current session state (snapshot + replayed events)
|
|
8
|
-
* POST /api/events Append review session events
|
|
8
|
+
* POST /api/events Append review session events to the stored log (same
|
|
9
|
+
* validation as MCP server), compare-and-swap on the
|
|
10
|
+
* revision the client last read
|
|
9
11
|
* GET /api/stream SSE stream: emits "update" events when the session file changes
|
|
10
12
|
* GET /api/health Health check
|
|
11
13
|
* GET /dist/* Compiled assets served from the dist tree (traversal-safe)
|
|
@@ -5,18 +5,20 @@
|
|
|
5
5
|
* Routes:
|
|
6
6
|
* GET / HTML shell that mounts the workbench
|
|
7
7
|
* GET /api/session Current session state (snapshot + replayed events)
|
|
8
|
-
* POST /api/events Append review session events
|
|
8
|
+
* POST /api/events Append review session events to the stored log (same
|
|
9
|
+
* validation as MCP server), compare-and-swap on the
|
|
10
|
+
* revision the client last read
|
|
9
11
|
* GET /api/stream SSE stream: emits "update" events when the session file changes
|
|
10
12
|
* GET /api/health Health check
|
|
11
13
|
* GET /dist/* Compiled assets served from the dist tree (traversal-safe)
|
|
12
14
|
*/
|
|
13
15
|
import { createServer } from "node:http";
|
|
14
16
|
import { watch } from "node:fs";
|
|
15
|
-
import { readFile as readFileAsync
|
|
17
|
+
import { readFile as readFileAsync } from "node:fs/promises";
|
|
16
18
|
import { resolve, join, dirname, extname, normalize, sep } from "node:path";
|
|
17
19
|
import { fileURLToPath } from "node:url";
|
|
18
|
-
import { defaultReviewSessionName, } from "../review-workbench/review-workbench.js";
|
|
19
20
|
import { createServerReviewSessionRecord, currentSessionState, deriveServerReviewSessionApplyResult, } from "../review-workbench/server-review-session.js";
|
|
21
|
+
import { appendReviewSessionEvents, readReviewSessionFile, reviewSessionRevision, storedReviewSessionName, updateReviewSessionFile, } from "../review-session-file.js";
|
|
20
22
|
// ---------------------------------------------------------------------------
|
|
21
23
|
// Constants
|
|
22
24
|
// ---------------------------------------------------------------------------
|
|
@@ -55,13 +57,7 @@ function distRoot() {
|
|
|
55
57
|
// Session file helpers
|
|
56
58
|
// ---------------------------------------------------------------------------
|
|
57
59
|
async function readSession(path) {
|
|
58
|
-
|
|
59
|
-
return JSON.parse(raw);
|
|
60
|
-
}
|
|
61
|
-
async function writeSessionAtomic(path, content) {
|
|
62
|
-
const tmp = `${path}.tmp`;
|
|
63
|
-
await writeFileAsync(tmp, JSON.stringify(content, null, 2), "utf8");
|
|
64
|
-
await renameAsync(tmp, path);
|
|
60
|
+
return readReviewSessionFile(path);
|
|
65
61
|
}
|
|
66
62
|
// ---------------------------------------------------------------------------
|
|
67
63
|
// SSE broadcaster
|
|
@@ -271,6 +267,14 @@ body {
|
|
|
271
267
|
.connection-dot.disconnected {
|
|
272
268
|
background: var(--k-negative, #ff6f6f);
|
|
273
269
|
}
|
|
270
|
+
.console-save-status {
|
|
271
|
+
padding: 8px 16px;
|
|
272
|
+
border-bottom: 1px solid var(--k-negative, #ff6f6f);
|
|
273
|
+
background: var(--k-panel-raised, #16202d);
|
|
274
|
+
color: var(--k-text, #eef3f8);
|
|
275
|
+
font-size: 13px;
|
|
276
|
+
}
|
|
277
|
+
.console-save-status[hidden] { display: none; }
|
|
274
278
|
#review-workbench {
|
|
275
279
|
flex: 1;
|
|
276
280
|
min-height: 0;
|
|
@@ -309,31 +313,92 @@ body {
|
|
|
309
313
|
</button>
|
|
310
314
|
</div>
|
|
311
315
|
</header>
|
|
316
|
+
<div id="console-save-status" class="console-save-status" role="alert" data-testid="console-save-status" hidden></div>
|
|
312
317
|
<main id="review-workbench" class="workbench survey-workbench-embed" data-testid="review-workbench"></main>
|
|
313
318
|
<script type="module">
|
|
314
319
|
import { mountReviewWorkbench, replayReviewSessionEvents, defaultReviewSessionName, buildReviewSessionEvents } from "${workbenchJsPath}";
|
|
315
320
|
|
|
316
321
|
// ---- persistence adapter: POST events to /api/events ----
|
|
317
|
-
|
|
318
|
-
|
|
322
|
+
// One store per mount, bound to the server revision the mount was built from.
|
|
323
|
+
// Each save sends only the workbench events of this mount that are not stored
|
|
324
|
+
// yet; the server appends them to the stored log, so reversals and note
|
|
325
|
+
// changes stay on record and decisions another writer stored (for example the
|
|
326
|
+
// MCP server) are kept. Saves are serialized so each one carries the revision
|
|
327
|
+
// the previous save produced. A save the server refuses (409 conflict or any
|
|
328
|
+
// other failure) is never kept as local truth: the console re-fetches the
|
|
329
|
+
// stored session, re-mounts from it and tells the reviewer once.
|
|
330
|
+
let saveChain = Promise.resolve();
|
|
331
|
+
|
|
332
|
+
function showSaveStatus(message) {
|
|
333
|
+
const el = document.getElementById("console-save-status");
|
|
334
|
+
if (!el) return;
|
|
335
|
+
if (message) {
|
|
336
|
+
el.textContent = message;
|
|
337
|
+
el.hidden = false;
|
|
338
|
+
} else {
|
|
339
|
+
el.textContent = "";
|
|
340
|
+
el.hidden = true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function createConsoleEventStore(baseRevision) {
|
|
345
|
+
let revision = baseRevision;
|
|
346
|
+
// Local events of this mount already appended to the stored log.
|
|
347
|
+
let persistedCount = 0;
|
|
348
|
+
// Set once a save from this mount is refused. Saves still queued from this
|
|
349
|
+
// mount were built on the view the server just refused, and the page has
|
|
350
|
+
// re-mounted from the stored session and told the reviewer: drop them
|
|
351
|
+
// instead of reporting the same conflict again.
|
|
352
|
+
let abandoned = false;
|
|
353
|
+
|
|
354
|
+
async function refused(message) {
|
|
355
|
+
abandoned = true;
|
|
356
|
+
showSaveStatus(message);
|
|
357
|
+
await fetchAndMount();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function persist(localEvents) {
|
|
361
|
+
if (abandoned) return;
|
|
362
|
+
const pending = localEvents.slice(persistedCount);
|
|
363
|
+
if (pending.length === 0) return;
|
|
364
|
+
let res;
|
|
365
|
+
try {
|
|
366
|
+
res = await fetch("/api/events", {
|
|
367
|
+
method: "POST",
|
|
368
|
+
headers: { "Content-Type": "application/json" },
|
|
369
|
+
body: JSON.stringify({ events: pending, baseRevision: revision }),
|
|
370
|
+
});
|
|
371
|
+
} catch (err) {
|
|
372
|
+
console.error("[console] Failed to persist events:", err);
|
|
373
|
+
await refused("Your last change was not saved: the console could not reach the server.");
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (res.ok) {
|
|
377
|
+
const body = await res.json().catch(() => ({}));
|
|
378
|
+
if (typeof body.revision === "string") revision = body.revision;
|
|
379
|
+
persistedCount = localEvents.length;
|
|
380
|
+
showSaveStatus("");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (res.status === 409) {
|
|
384
|
+
await refused("Your last change was not saved because the session changed (another tab, reviewer or agent wrote to it). The console reloaded the current session.");
|
|
385
|
+
} else {
|
|
386
|
+
const body = await res.json().catch(() => ({}));
|
|
387
|
+
await refused("Your last change was not saved (HTTP " + res.status + (body.error ? ": " + body.error : "") + "). The console reloaded the stored session.");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
319
391
|
return {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
headers: { "Content-Type": "application/json" },
|
|
327
|
-
body: JSON.stringify({ events }),
|
|
328
|
-
});
|
|
329
|
-
} catch (err) {
|
|
330
|
-
console.error("[console] Failed to persist events:", err);
|
|
331
|
-
}
|
|
392
|
+
// The mount state is already replayed from the stored log.
|
|
393
|
+
load: () => undefined,
|
|
394
|
+
save: (_session, events) => {
|
|
395
|
+
const localEvents = [...events];
|
|
396
|
+
saveChain = saveChain.then(() => persist(localEvents));
|
|
397
|
+
return saveChain;
|
|
332
398
|
},
|
|
333
399
|
};
|
|
334
400
|
}
|
|
335
401
|
|
|
336
|
-
const eventStore = createConsoleEventStore();
|
|
337
402
|
let activeItemName = null;
|
|
338
403
|
|
|
339
404
|
async function fetchAndMount() {
|
|
@@ -341,7 +406,7 @@ async function fetchAndMount() {
|
|
|
341
406
|
const res = await fetch("/api/session");
|
|
342
407
|
if (!res.ok) throw new Error("Session fetch failed: " + res.status);
|
|
343
408
|
const data = await res.json();
|
|
344
|
-
const { snapshot, events } = data;
|
|
409
|
+
const { snapshot, events, revision } = data;
|
|
345
410
|
const state = events && events.length > 0
|
|
346
411
|
? replayReviewSessionEvents(snapshot, events)
|
|
347
412
|
: snapshot;
|
|
@@ -354,7 +419,7 @@ async function fetchAndMount() {
|
|
|
354
419
|
const root = document.getElementById("review-workbench");
|
|
355
420
|
if (!root) return;
|
|
356
421
|
|
|
357
|
-
mountReviewWorkbench(root, state, { eventStore });
|
|
422
|
+
mountReviewWorkbench(root, state, { eventStore: createConsoleEventStore(revision) });
|
|
358
423
|
} catch (err) {
|
|
359
424
|
console.error("[console] Mount error:", err);
|
|
360
425
|
}
|
|
@@ -451,6 +516,49 @@ async function readJsonBody(req) {
|
|
|
451
516
|
return parsed;
|
|
452
517
|
}
|
|
453
518
|
// ---------------------------------------------------------------------------
|
|
519
|
+
// Request guards (kontourai/survey#281)
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
function hostnameOf(hostHeader) {
|
|
522
|
+
try {
|
|
523
|
+
return new URL(`http://${hostHeader}`).hostname.replace(/^\[|\]$/g, "");
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return undefined;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* The server binds to loopback only, but a browser page on another site can
|
|
531
|
+
* still reach it through DNS rebinding (Host is the attacker's name) or a
|
|
532
|
+
* cross-origin "simple request" (text/plain body, foreign Origin). Each guard
|
|
533
|
+
* returns an error message, or undefined when the request passes.
|
|
534
|
+
*/
|
|
535
|
+
function hostGuard(req) {
|
|
536
|
+
const host = req.headers.host;
|
|
537
|
+
const hostname = host ? hostnameOf(host) : undefined;
|
|
538
|
+
return hostname && LOOPBACK_HOSTS.has(hostname) ? undefined : "Host must be a loopback address";
|
|
539
|
+
}
|
|
540
|
+
function originGuard(req, port) {
|
|
541
|
+
const origin = req.headers.origin;
|
|
542
|
+
if (origin === undefined)
|
|
543
|
+
return undefined;
|
|
544
|
+
try {
|
|
545
|
+
const parsed = new URL(origin);
|
|
546
|
+
const originPort = parsed.port === "" ? "80" : parsed.port;
|
|
547
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
548
|
+
if (parsed.protocol === "http:" && LOOPBACK_HOSTS.has(hostname) && originPort === String(port)) {
|
|
549
|
+
return undefined;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
// Unparseable (for example "null"): refuse below.
|
|
554
|
+
}
|
|
555
|
+
return "Cross-origin writes are not allowed";
|
|
556
|
+
}
|
|
557
|
+
function contentTypeGuard(req) {
|
|
558
|
+
const mediaType = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
|
|
559
|
+
return mediaType === "application/json" ? undefined : "Content-Type must be application/json";
|
|
560
|
+
}
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
454
562
|
// Public API
|
|
455
563
|
// ---------------------------------------------------------------------------
|
|
456
564
|
export async function startReviewConsoleServer(options) {
|
|
@@ -464,8 +572,14 @@ export async function startReviewConsoleServer(options) {
|
|
|
464
572
|
const stopWatcher = watchSessionFile(sessionPath, () => {
|
|
465
573
|
broadcaster.broadcast("update", JSON.stringify({ ts: Date.now() }));
|
|
466
574
|
});
|
|
575
|
+
let listenPort = 0;
|
|
467
576
|
const server = createServer(async (req, res) => {
|
|
468
577
|
try {
|
|
578
|
+
const hostError = hostGuard(req);
|
|
579
|
+
if (hostError) {
|
|
580
|
+
sendJson(res, 403, { error: hostError });
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
469
583
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
470
584
|
const pathname = url.pathname;
|
|
471
585
|
// ---- Static dist assets ----
|
|
@@ -492,42 +606,80 @@ export async function startReviewConsoleServer(options) {
|
|
|
492
606
|
session: content.session,
|
|
493
607
|
snapshot: content.snapshot,
|
|
494
608
|
events: content.events,
|
|
609
|
+
revision: reviewSessionRevision(content.events),
|
|
495
610
|
state: currentSessionState(content.snapshot, content.events),
|
|
496
611
|
});
|
|
497
612
|
return;
|
|
498
613
|
}
|
|
499
614
|
// ---- Events write (append) ----
|
|
500
615
|
if (pathname === "/api/events" && req.method === "POST") {
|
|
616
|
+
const originError = originGuard(req, listenPort);
|
|
617
|
+
if (originError) {
|
|
618
|
+
sendJson(res, 403, { error: originError });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const contentTypeError = contentTypeGuard(req);
|
|
622
|
+
if (contentTypeError) {
|
|
623
|
+
sendJson(res, 415, { error: contentTypeError });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
501
626
|
const body = await readJsonBody(req);
|
|
502
627
|
const incomingEvents = body.events;
|
|
628
|
+
const baseRevision = body.baseRevision;
|
|
503
629
|
if (!Array.isArray(incomingEvents)) {
|
|
504
630
|
sendJson(res, 400, { error: "events must be an array" });
|
|
505
631
|
return;
|
|
506
632
|
}
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
633
|
+
const outcome = await updateReviewSessionFile(sessionPath, (content) => {
|
|
634
|
+
const { snapshot, events: existingEvents } = content;
|
|
635
|
+
const revision = reviewSessionRevision(existingEvents);
|
|
636
|
+
// Compare-and-swap: the appended events express decisions made on
|
|
637
|
+
// the reviewer's view of the log, so they are only safe to append
|
|
638
|
+
// when that view is the log that is stored right now.
|
|
639
|
+
if (typeof baseRevision !== "string") {
|
|
640
|
+
return { result: { status: 428, body: { error: "baseRevision is required: send the revision from GET /api/session", revision, eventCount: existingEvents.length } } };
|
|
641
|
+
}
|
|
642
|
+
if (baseRevision !== revision) {
|
|
643
|
+
return { result: { status: 409, body: { error: "Session changed since it was read; reload and retry", revision, eventCount: existingEvents.length } } };
|
|
644
|
+
}
|
|
645
|
+
if (incomingEvents.length === 0) {
|
|
646
|
+
return { result: { status: 422, body: { error: "events must contain at least one event to append", revision, eventCount: existingEvents.length } } };
|
|
647
|
+
}
|
|
648
|
+
// Append, never replace: the stored log stays a complete record of
|
|
649
|
+
// reviewer intent (reversals and note changes included).
|
|
650
|
+
const nextEvents = appendReviewSessionEvents(content, incomingEvents);
|
|
651
|
+
const record = createServerReviewSessionRecord({
|
|
652
|
+
sessionName: storedReviewSessionName(content),
|
|
653
|
+
snapshot,
|
|
654
|
+
eventCount: existingEvents.length,
|
|
655
|
+
updatedAt: new Date(),
|
|
656
|
+
});
|
|
657
|
+
let issueMessages;
|
|
658
|
+
try {
|
|
659
|
+
const applyResult = deriveServerReviewSessionApplyResult({
|
|
660
|
+
record,
|
|
661
|
+
events: nextEvents,
|
|
662
|
+
requiredResolvedItems: "none",
|
|
663
|
+
});
|
|
664
|
+
issueMessages = applyResult.ok
|
|
665
|
+
? []
|
|
666
|
+
: applyResult.issues.map((issue) => ("message" in issue ? issue.message : String(issue)));
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
issueMessages = [error instanceof Error ? error.message : String(error)];
|
|
670
|
+
}
|
|
671
|
+
if (issueMessages.length > 0) {
|
|
672
|
+
return { result: { status: 422, body: { error: `Validation failed: ${issueMessages.join("; ")}` } } };
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
next: { session: content.session, snapshot, events: nextEvents },
|
|
676
|
+
result: {
|
|
677
|
+
status: 200,
|
|
678
|
+
body: { ok: true, eventCount: nextEvents.length, revision: reviewSessionRevision(nextEvents) },
|
|
679
|
+
},
|
|
680
|
+
};
|
|
529
681
|
});
|
|
530
|
-
sendJson(res,
|
|
682
|
+
sendJson(res, outcome.status, outcome.body);
|
|
531
683
|
return;
|
|
532
684
|
}
|
|
533
685
|
// ---- Health ----
|
|
@@ -558,6 +710,7 @@ export async function startReviewConsoleServer(options) {
|
|
|
558
710
|
if (!address || typeof address === "string") {
|
|
559
711
|
throw new Error("Unable to determine server address");
|
|
560
712
|
}
|
|
713
|
+
listenPort = address.port;
|
|
561
714
|
const normalizedHost = host === "::1" ? "[::1]" : host;
|
|
562
715
|
const url = `http://${normalizedHost}:${address.port}/`;
|
|
563
716
|
return {
|