@danceiny/gotry 0.0.1-rc.17 → 0.0.1-rc.19
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 +185 -148
- package/README.zh-CN.md +195 -154
- package/bin/gotry-bootstrap.js +246 -26
- package/bin/gotry-inner.js +68 -38
- package/bin/gotry-runtime-resolution.d.ts +1 -1
- package/bin/gotry-runtime-resolution.js +7 -8
- package/cordis.gotry-patch.yml +15 -5
- package/data/stations-12306-verify.json +524 -0
- package/dist/capabilities/agent-reach.js +4 -4
- package/dist/capabilities/channel-health.js +103 -0
- package/dist/capabilities/channel-registry.js +213 -0
- package/dist/capabilities/doctor.js +285 -0
- package/dist/capabilities/effect.js +178 -5
- package/dist/capabilities/flyai.js +13 -0
- package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
- package/dist/capabilities/session/adapters/rail-12306.js +285 -0
- package/dist/capabilities/session/extension-bridge.js +14 -1
- package/dist/capabilities/session/extension-channel.js +4 -2
- package/dist/capabilities/session/extension-distribution.js +1 -1
- package/dist/capabilities/session-search.js +311 -0
- package/dist/capabilities/visa-policy.js +126 -0
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +304 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
- package/dist/scripts/benchmark-environment-bridge-e2e.js +5 -10
- package/dist/scripts/benchmark-environment-bridge-tests.js +148 -142
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
- package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
- package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
- package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
- package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
- package/dist/scripts/bootstrap-tests.js +115 -44
- package/dist/scripts/build-metrics-report.js +339 -0
- package/dist/scripts/channel-probe-tests.js +148 -0
- package/dist/scripts/channel-probe.js +252 -0
- package/dist/scripts/channel-registry-tests.js +262 -0
- package/dist/scripts/doctor-tests.js +121 -0
- package/dist/scripts/effect-tests.js +274 -1
- package/dist/scripts/extension-tests.js +129 -11
- package/dist/scripts/fact-gate-tests.js +91 -2
- package/dist/scripts/flyai-tests.js +17 -1
- package/dist/scripts/hbcli-e2e-tests.js +1 -3
- package/dist/scripts/metrics-report-tests.js +288 -0
- package/dist/scripts/persona-surface-guard-tests.js +19 -0
- package/dist/scripts/session-tests.js +401 -1
- package/dist/scripts/smoke.js +100 -83
- package/dist/scripts/turn-handoff-collect-tests.js +216 -0
- package/dist/scripts/turn-handoff-collect.js +189 -0
- package/dist/scripts/turn-policy-tests.js +56 -0
- package/dist/scripts/typed-contract-canary.js +261 -0
- package/dist/scripts/visa-policy-tests.js +75 -0
- package/dist/scripts/wish-channel-gate-tests.js +111 -0
- package/dist/src/artifact-gate.js +91 -3
- package/dist/src/benchmark-agent-conformance.js +7 -4
- package/dist/src/benchmark-environment-bridge.js +68 -7
- package/dist/src/bookable-facts.js +62 -3
- package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
- package/dist/src/booking-surface/canonical-schema.js +8 -22
- package/dist/src/booking-surface/contracts.js +70 -19
- package/dist/src/booking-surface/dsh-planner.js +310 -177
- package/dist/src/booking-surface/dsh-plugin.js +3 -3
- package/dist/src/booking-surface/index.js +1 -5
- package/dist/src/booking-surface/profile.js +1 -1
- package/dist/src/booking-surface/runtime.js +1682 -247
- package/dist/src/booking-surface/server.js +371 -185
- package/dist/src/booking-surface/startup.js +17 -27
- package/dist/src/booking-surface/validation.js +270 -760
- package/dist/src/index.js +735 -149
- package/dist/src/turn-deadline.js +292 -0
- package/dist/src/turn-policy.js +146 -0
- package/dist/src/wish-pool.js +5 -0
- package/extension/background.js +23 -4
- package/extension/content-main.js +47 -9
- package/extension/manifest.json +27 -9
- package/package.json +6 -26
- package/schemas/booking.surface.schema.json +2593 -0
- package/ts/capabilities/agent-reach.ts +4 -4
- package/ts/capabilities/flyai.ts +21 -3
- package/ts/capabilities/session/action-cache.ts +1 -1
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
- package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
- package/ts/capabilities/session/extension-bridge.ts +32 -2
- package/ts/capabilities/session/extension-channel.ts +9 -8
- package/ts/capabilities/session/extension-distribution.ts +3 -1
- package/ts/capabilities/session/health-watch.ts +1 -1
- package/ts/capabilities/session/read-guard.ts +1 -1
- package/ts/capabilities/session/wizard.ts +1 -1
- package/ts/capabilities/session-search.ts +323 -1
- package/ts/package.json +1 -0
- package/ts/scripts/turn-handoff-collect.ts +178 -0
- package/ts/src/artifact-gate.ts +82 -2
- package/ts/src/benchmark-agent-conformance.ts +14 -6
- package/ts/src/benchmark-environment-bridge.ts +29 -10
- package/ts/src/bookable-facts.ts +109 -5
- package/ts/src/booking-saga.ts +1 -1
- package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
- package/ts/src/booking-surface/canonical-schema.js +8 -22
- package/ts/src/booking-surface/contracts.ts +278 -239
- package/ts/src/booking-surface/dsh-planner.ts +333 -194
- package/ts/src/booking-surface/dsh-plugin.js +3 -3
- package/ts/src/booking-surface/index.ts +1 -5
- package/ts/src/booking-surface/profile.ts +11 -11
- package/ts/src/booking-surface/runtime.ts +1351 -351
- package/ts/src/booking-surface/server.ts +282 -175
- package/ts/src/booking-surface/startup.ts +36 -48
- package/ts/src/booking-surface/validation.ts +194 -442
- package/ts/src/contracts.ts +1 -1
- package/ts/src/index.ts +489 -171
- package/ts/src/loop.ts +1 -1
- package/ts/src/state-ledger.ts +1 -1
- package/ts/src/turn-deadline.ts +361 -0
- package/ts/src/turn-policy.ts +154 -0
- package/ts/src/wish-pool.ts +17 -4
- package/dist/scripts/agent-planning-budget-e2e.js +0 -227
- package/dist/scripts/agent-planning-budget-tests.js +0 -173
- package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
- package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
- package/dist/src/booking-surface/contracts-v2.js +0 -89
- package/dist/src/booking-surface/runtime-v2.js +0 -1771
- package/dist/src/booking-surface/server-v2.js +0 -334
- package/dist/src/booking-surface/validation-v2.js +0 -319
- package/dist/src/tool-budget.js +0 -136
- package/schemas/booking.surface.v1.schema.json +0 -927
- package/schemas/booking.surface.v2.schema.json +0 -61
- package/ts/src/booking-surface/contracts-v2.ts +0 -118
- package/ts/src/booking-surface/runtime-v2.ts +0 -1466
- package/ts/src/booking-surface/server-v2.ts +0 -247
- package/ts/src/booking-surface/validation-v2.ts +0 -205
- package/ts/src/tool-budget.ts +0 -165
|
@@ -1,809 +1,319 @@
|
|
|
1
|
-
import { BOOKING_READ_ACTION_KINDS, BOOKING_RECEIPT_STATUSES, BOOKING_SURFACES, BOOKING_SURFACE_EVENT_KINDS } from './contracts.js';
|
|
2
|
-
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
3
1
|
import { readFileSync } from 'node:fs';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
2
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
3
|
+
import { BOOKING_READ_ACTION_KINDS, BOOKING_BLOCKER_CODES, BOOKING_GAP_CODES } from './contracts.js';
|
|
4
|
+
const schema = JSON.parse(readFileSync(new URL('../../../schemas/booking.surface.schema.json', import.meta.url), 'utf8'));
|
|
5
|
+
const ajv = new Ajv2020({
|
|
7
6
|
allErrors: true,
|
|
8
7
|
strict: true
|
|
9
8
|
});
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
const validateCanonicalReceipt = canonicalAjv.compile({
|
|
15
|
-
$ref: `${CANONICAL_SCHEMA_ID}#/$defs/ActionReceiptV1`
|
|
9
|
+
ajv.addSchema(schema);
|
|
10
|
+
const validate = ajv.compile(schema);
|
|
11
|
+
const validateActionCanonical = ajv.compile({
|
|
12
|
+
$ref: `${schema.$id}#/$defs/Action`
|
|
16
13
|
});
|
|
17
|
-
const
|
|
18
|
-
$ref: `${
|
|
14
|
+
const validateBlockerCanonical = ajv.compile({
|
|
15
|
+
$ref: `${schema.$id}#/$defs/Blocker`
|
|
19
16
|
});
|
|
20
|
-
const
|
|
21
|
-
$ref: `${
|
|
17
|
+
const validateApprovalCanonical = ajv.compile({
|
|
18
|
+
$ref: `${schema.$id}#/$defs/Approval`
|
|
22
19
|
});
|
|
23
|
-
const
|
|
24
|
-
$ref: `${
|
|
20
|
+
const validateEventCanonical = ajv.compile({
|
|
21
|
+
$ref: `${schema.$id}#/$defs/Event`
|
|
25
22
|
});
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
23
|
+
const kinds = new Set(BOOKING_READ_ACTION_KINDS);
|
|
24
|
+
const codes = new Set(BOOKING_BLOCKER_CODES);
|
|
25
|
+
const gapCodes = new Set(BOOKING_GAP_CODES);
|
|
26
|
+
const safeOpaque = (value)=>typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value);
|
|
27
|
+
const safeRequestKey = (value)=>typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{7,127}$/.test(value) && !secret.test(value);
|
|
28
|
+
const secret = /(?:Bearer\s+\S|\bsk-(?!ill(?:s)?\b)[A-Za-z0-9_-]{12,}|\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i;
|
|
29
|
+
const technicalEmail = /^(?:schema|version)@[a-z0-9-]+\.example$/i;
|
|
30
|
+
const currencyAmount = /\b(?:[A-Z]{3}\s*\d+(?:\.\d+)?|\d+(?:\.\d+)?\s*[A-Z]{3})\b/;
|
|
31
|
+
const rfc3339DateTime = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/;
|
|
32
|
+
export function isBookingDateTime(value) {
|
|
33
|
+
if (typeof value !== 'string') return false;
|
|
34
|
+
const match = rfc3339DateTime.exec(value);
|
|
35
|
+
if (!match) return false;
|
|
36
|
+
const [, yearText, monthText, dayText, hourText, minuteText, secondText] = match;
|
|
37
|
+
const year = Number(yearText);
|
|
38
|
+
const month = Number(monthText);
|
|
39
|
+
const day = Number(dayText);
|
|
40
|
+
const hour = Number(hourText);
|
|
41
|
+
const minute = Number(minuteText);
|
|
42
|
+
const second = Number(secondText);
|
|
43
|
+
const offsetHour = match[9] === undefined ? 0 : Number(match[9]);
|
|
44
|
+
const offsetMinute = match[10] === undefined ? 0 : Number(match[10]);
|
|
45
|
+
if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) return false;
|
|
46
|
+
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
47
|
+
if (day < 1 || day > daysInMonth) return false;
|
|
48
|
+
return !Number.isNaN(Date.parse(value));
|
|
38
49
|
}
|
|
39
|
-
function
|
|
40
|
-
return
|
|
50
|
+
function normalizedWords(value) {
|
|
51
|
+
return value.normalize('NFKC').replace(/[^\p{L}\p{N}.]+/gu, ' ').trim();
|
|
41
52
|
}
|
|
42
|
-
function
|
|
43
|
-
|
|
53
|
+
function hasSupplierCost(value) {
|
|
54
|
+
const normalized = normalizedWords(value);
|
|
55
|
+
const lower = normalized.toLowerCase();
|
|
56
|
+
return /\b(?:supplier|vendor|internal)\b/.test(lower) && /\b(?:cost|rate|price)\b/.test(lower) && currencyAmount.test(normalized.toUpperCase());
|
|
44
57
|
}
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
function hasInternationalPhone(value) {
|
|
59
|
+
const normalized = value.normalize('NFKC');
|
|
60
|
+
for (const plusIndex of normalized.split('').flatMap((char, index)=>char === '+' ? [
|
|
61
|
+
index
|
|
62
|
+
] : [])){
|
|
63
|
+
let digits = '';
|
|
64
|
+
for (const char of normalized.slice(plusIndex + 1, plusIndex + 48)){
|
|
65
|
+
if (/\d/u.test(char)) digits += char;
|
|
66
|
+
else if (/\p{L}|@/u.test(char)) break;
|
|
67
|
+
if (digits.length > 15) break;
|
|
68
|
+
}
|
|
69
|
+
if (/^[1-9]\d{7,14}$/.test(digits)) return true;
|
|
70
|
+
}
|
|
52
71
|
return false;
|
|
53
72
|
}
|
|
54
|
-
function
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
function validateIngressWorkspace(value, at, errors) {
|
|
60
|
-
if (!isRecord(value)) {
|
|
61
|
-
errors.push(`${at}: object required`);
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
exactKeys(value, [
|
|
65
|
-
'schemaVersion',
|
|
66
|
-
'revision',
|
|
67
|
-
'locale',
|
|
68
|
-
'currency',
|
|
69
|
-
'searchDraft',
|
|
70
|
-
'results',
|
|
71
|
-
'visibleHotels',
|
|
72
|
-
'loadedOffers',
|
|
73
|
-
'focusedHotelRef',
|
|
74
|
-
'shortlistedOfferRefs',
|
|
75
|
-
'selectedOfferRef',
|
|
76
|
-
'verifiedOfferRef'
|
|
77
|
-
], [
|
|
78
|
-
'schemaVersion',
|
|
79
|
-
'revision',
|
|
80
|
-
'locale',
|
|
81
|
-
'currency',
|
|
82
|
-
'searchDraft',
|
|
83
|
-
'results',
|
|
84
|
-
'visibleHotels',
|
|
85
|
-
'loadedOffers',
|
|
86
|
-
'shortlistedOfferRefs'
|
|
87
|
-
], at, errors);
|
|
88
|
-
if (value.schemaVersion !== 'booking.surface.v1') errors.push(`${at}.schemaVersion: booking.surface.v1 required`);
|
|
89
|
-
if (!isRevision(value.revision)) errors.push(`${at}.revision: non-negative integer required`);
|
|
90
|
-
if (!isNonEmptyString(value.locale)) errors.push(`${at}.locale: non-empty string required`);
|
|
91
|
-
if (typeof value.currency !== 'string' || !/^[A-Z]{3}$/.test(value.currency)) errors.push(`${at}.currency: ISO-4217 code required`);
|
|
92
|
-
if (!isRecord(value.searchDraft)) errors.push(`${at}.searchDraft: object required`);
|
|
93
|
-
if (!isRecord(value.results)) errors.push(`${at}.results: object required`);
|
|
94
|
-
if (!Array.isArray(value.visibleHotels)) errors.push(`${at}.visibleHotels: array required`);
|
|
95
|
-
if (!Array.isArray(value.loadedOffers)) errors.push(`${at}.loadedOffers: array required`);
|
|
96
|
-
if (!isStringArray(value.shortlistedOfferRefs)) errors.push(`${at}.shortlistedOfferRefs: opaque-ref array required`);
|
|
97
|
-
return errors.length === 0;
|
|
73
|
+
function hasPaymentCard(value) {
|
|
74
|
+
const normalized = value.normalize('NFKC');
|
|
75
|
+
const candidates = normalized.match(/(?:\d[ -]?){12,18}\d/g) ?? [];
|
|
76
|
+
const explicitlyLabeled = /\b(?:card|payment|visa|mastercard)\b/i.test(normalized);
|
|
77
|
+
return candidates.some((candidate)=>(explicitlyLabeled || /[ -]/.test(candidate)) && luhn(candidate));
|
|
98
78
|
}
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
const { contextRef: _contextRef, surface: _surface, capabilities: _capabilities, ...ingressShape } = value;
|
|
105
|
-
validateIngressWorkspace(ingressShape, at, errors);
|
|
106
|
-
const allowed = [
|
|
107
|
-
'schemaVersion',
|
|
108
|
-
'contextRef',
|
|
109
|
-
'surface',
|
|
110
|
-
'revision',
|
|
111
|
-
'locale',
|
|
112
|
-
'currency',
|
|
113
|
-
'searchDraft',
|
|
114
|
-
'results',
|
|
115
|
-
'visibleHotels',
|
|
116
|
-
'loadedOffers',
|
|
117
|
-
'focusedHotelRef',
|
|
118
|
-
'shortlistedOfferRefs',
|
|
119
|
-
'selectedOfferRef',
|
|
120
|
-
'verifiedOfferRef',
|
|
121
|
-
'capabilities'
|
|
122
|
-
];
|
|
123
|
-
exactKeys(value, allowed, [
|
|
124
|
-
'schemaVersion',
|
|
125
|
-
'contextRef',
|
|
126
|
-
'surface',
|
|
127
|
-
'revision',
|
|
128
|
-
'locale',
|
|
129
|
-
'currency',
|
|
130
|
-
'searchDraft',
|
|
131
|
-
'results',
|
|
132
|
-
'visibleHotels',
|
|
133
|
-
'loadedOffers',
|
|
134
|
-
'shortlistedOfferRefs',
|
|
135
|
-
'capabilities'
|
|
136
|
-
], at, errors);
|
|
137
|
-
if (!isNonEmptyString(value.contextRef)) errors.push(`${at}.contextRef: server-minted opaque ref required`);
|
|
138
|
-
if (typeof value.surface !== 'string' || !SURFACES.has(value.surface)) errors.push(`${at}.surface: unsupported surface`);
|
|
139
|
-
if (!isRecord(value.capabilities)) {
|
|
140
|
-
errors.push(`${at}.capabilities: object required`);
|
|
141
|
-
} else {
|
|
142
|
-
exactKeys(value.capabilities, [
|
|
143
|
-
'surface',
|
|
144
|
-
'allowedActions'
|
|
145
|
-
], [
|
|
146
|
-
'surface',
|
|
147
|
-
'allowedActions'
|
|
148
|
-
], `${at}.capabilities`, errors);
|
|
149
|
-
if (value.capabilities.surface !== value.surface) errors.push(`${at}.capabilities.surface: must equal workspace.surface`);
|
|
150
|
-
const actionKinds = value.capabilities.allowedActions;
|
|
151
|
-
if (!Array.isArray(actionKinds) || !actionKinds.every((kind)=>typeof kind === 'string' && ACTION_KINDS.has(kind))) {
|
|
152
|
-
errors.push(`${at}.capabilities.allowedActions: closed action-kind array required`);
|
|
153
|
-
} else if (new Set(actionKinds).size !== actionKinds.length) {
|
|
154
|
-
errors.push(`${at}.capabilities.allowedActions: duplicates forbidden`);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
appendCanonicalErrors(validateCanonicalWorkspace, value, at, errors);
|
|
158
|
-
return errors.length === 0;
|
|
79
|
+
function hasSensitiveText(value, opts = {}) {
|
|
80
|
+
const normalized = value.normalize('NFKC');
|
|
81
|
+
if (normalized.includes('@') && !technicalEmail.test(normalized.trim())) return true;
|
|
82
|
+
return secret.test(normalized) || hasInternationalPhone(normalized) || hasSupplierCost(normalized) || !opts.allowOpaqueNumericId && hasPaymentCard(normalized);
|
|
159
83
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
'search.run': {
|
|
170
|
-
allowed: [],
|
|
171
|
-
required: []
|
|
172
|
-
},
|
|
173
|
-
'results.view.patch': {
|
|
174
|
-
allowed: [
|
|
175
|
-
'patch'
|
|
176
|
-
],
|
|
177
|
-
required: [
|
|
178
|
-
'patch'
|
|
179
|
-
]
|
|
180
|
-
},
|
|
181
|
-
'hotel.focus': {
|
|
182
|
-
allowed: [
|
|
183
|
-
'hotelRef'
|
|
184
|
-
],
|
|
185
|
-
required: [
|
|
186
|
-
'hotelRef'
|
|
187
|
-
]
|
|
188
|
-
},
|
|
189
|
-
'hotel.select': {
|
|
190
|
-
allowed: [
|
|
191
|
-
'hotelRef'
|
|
192
|
-
],
|
|
193
|
-
required: [
|
|
194
|
-
'hotelRef'
|
|
195
|
-
]
|
|
196
|
-
},
|
|
197
|
-
'offers.query': {
|
|
198
|
-
allowed: [
|
|
199
|
-
'hotelRefs',
|
|
200
|
-
'criteria'
|
|
201
|
-
],
|
|
202
|
-
required: [
|
|
203
|
-
'hotelRefs',
|
|
204
|
-
'criteria'
|
|
205
|
-
]
|
|
206
|
-
},
|
|
207
|
-
'offers.view.patch': {
|
|
208
|
-
allowed: [
|
|
209
|
-
'hotelRef',
|
|
210
|
-
'criteria'
|
|
211
|
-
],
|
|
212
|
-
required: [
|
|
213
|
-
'hotelRef',
|
|
214
|
-
'criteria'
|
|
215
|
-
]
|
|
216
|
-
},
|
|
217
|
-
'offers.compare': {
|
|
218
|
-
allowed: [
|
|
219
|
-
'offerRefs',
|
|
220
|
-
'requestedCount'
|
|
221
|
-
],
|
|
222
|
-
required: [
|
|
223
|
-
'offerRefs',
|
|
224
|
-
'requestedCount'
|
|
225
|
-
]
|
|
226
|
-
},
|
|
227
|
-
'offer.select': {
|
|
228
|
-
allowed: [
|
|
229
|
-
'offerRef'
|
|
230
|
-
],
|
|
231
|
-
required: [
|
|
232
|
-
'offerRef'
|
|
233
|
-
]
|
|
234
|
-
},
|
|
235
|
-
'offer.check': {
|
|
236
|
-
allowed: [
|
|
237
|
-
'offerRef'
|
|
238
|
-
],
|
|
239
|
-
required: [
|
|
240
|
-
'offerRef'
|
|
241
|
-
]
|
|
242
|
-
},
|
|
243
|
-
'checkout.prepare': {
|
|
244
|
-
allowed: [
|
|
245
|
-
'offerRef',
|
|
246
|
-
'verifiedOfferRef'
|
|
247
|
-
],
|
|
248
|
-
required: [
|
|
249
|
-
'offerRef',
|
|
250
|
-
'verifiedOfferRef'
|
|
251
|
-
]
|
|
252
|
-
},
|
|
253
|
-
'order.observe': {
|
|
254
|
-
allowed: [
|
|
255
|
-
'orderRef'
|
|
256
|
-
],
|
|
257
|
-
required: [
|
|
258
|
-
'orderRef'
|
|
259
|
-
]
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
function collectBookingReadActionErrors(value, at) {
|
|
263
|
-
const errors = [];
|
|
264
|
-
if (!isRecord(value)) return [
|
|
265
|
-
`${at}: typed action object required; text is never executable`
|
|
266
|
-
];
|
|
267
|
-
exactKeys(value, [
|
|
268
|
-
'schemaVersion',
|
|
269
|
-
'kind',
|
|
270
|
-
'actionId',
|
|
271
|
-
'contextRef',
|
|
272
|
-
'expectedRevision',
|
|
273
|
-
'reason',
|
|
274
|
-
'factRefs',
|
|
275
|
-
'input'
|
|
276
|
-
], [
|
|
277
|
-
'schemaVersion',
|
|
278
|
-
'kind',
|
|
279
|
-
'actionId',
|
|
280
|
-
'contextRef',
|
|
281
|
-
'expectedRevision',
|
|
282
|
-
'reason',
|
|
283
|
-
'factRefs',
|
|
284
|
-
'input'
|
|
285
|
-
], at, errors);
|
|
286
|
-
if (value.schemaVersion !== 'booking.surface.v1') errors.push(`${at}.schemaVersion: booking.surface.v1 required`);
|
|
287
|
-
if (typeof value.kind !== 'string' || !ACTION_KINDS.has(value.kind)) {
|
|
288
|
-
errors.push(`${at}.kind: unregistered read action`);
|
|
289
|
-
}
|
|
290
|
-
if (!isNonEmptyString(value.actionId)) errors.push(`${at}.actionId: opaque ref required`);
|
|
291
|
-
if (!isNonEmptyString(value.contextRef)) errors.push(`${at}.contextRef: opaque ref required`);
|
|
292
|
-
if (!isRevision(value.expectedRevision)) errors.push(`${at}.expectedRevision: non-negative integer required`);
|
|
293
|
-
if (!isNonEmptyString(value.reason)) errors.push(`${at}.reason: non-empty explanation required`);
|
|
294
|
-
if (!isStringArray(value.factRefs)) errors.push(`${at}.factRefs: opaque-ref array required`);
|
|
295
|
-
if (!isRecord(value.input)) {
|
|
296
|
-
errors.push(`${at}.input: typed object required`);
|
|
297
|
-
} else if (typeof value.kind === 'string' && ACTION_KINDS.has(value.kind)) {
|
|
298
|
-
const spec = ACTION_INPUT_KEYS[value.kind];
|
|
299
|
-
exactKeys(value.input, spec.allowed, spec.required, `${at}.input`, errors);
|
|
300
|
-
}
|
|
301
|
-
if (!validateCanonicalAction(value)) {
|
|
302
|
-
for (const error of validateCanonicalAction.errors ?? []){
|
|
303
|
-
errors.push(canonicalError(error, at));
|
|
84
|
+
function luhn(value) {
|
|
85
|
+
const digits = value.replaceAll(/\D/g, '');
|
|
86
|
+
if (digits.length < 13 || digits.length > 19) return false;
|
|
87
|
+
let sum = 0;
|
|
88
|
+
for(let i = digits.length - 1, parity = 0; i >= 0; i--, parity++){
|
|
89
|
+
let n = Number(digits[i]);
|
|
90
|
+
if (parity % 2 === 1) {
|
|
91
|
+
n *= 2;
|
|
92
|
+
if (n > 9) n -= 9;
|
|
304
93
|
}
|
|
94
|
+
sum += n;
|
|
305
95
|
}
|
|
306
|
-
return
|
|
96
|
+
return sum % 10 === 0;
|
|
307
97
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
98
|
+
const opaqueFieldNames = new Set([
|
|
99
|
+
'eventId',
|
|
100
|
+
'taskId',
|
|
101
|
+
'contextRef',
|
|
102
|
+
'actionId',
|
|
103
|
+
'hotelRef',
|
|
104
|
+
'offerRef',
|
|
105
|
+
'offerVersionRef',
|
|
106
|
+
'checkedOfferVersionRef',
|
|
107
|
+
'currentOfferVersionRef',
|
|
108
|
+
'verifiedOfferRef',
|
|
109
|
+
'orderRef',
|
|
110
|
+
'searchSessionRef',
|
|
111
|
+
'handoffRef',
|
|
112
|
+
'questionId',
|
|
113
|
+
'blockerId',
|
|
114
|
+
'approvalId',
|
|
115
|
+
'sourceActionId',
|
|
116
|
+
'targetActionId',
|
|
117
|
+
'sourceTurnId',
|
|
118
|
+
'turnId',
|
|
119
|
+
'requestKey',
|
|
120
|
+
'taskHandle',
|
|
121
|
+
'nonce',
|
|
122
|
+
'deliveryNonce',
|
|
123
|
+
'optionDigest',
|
|
124
|
+
'sourceReceiptDigest',
|
|
125
|
+
'valueDigest'
|
|
126
|
+
]);
|
|
127
|
+
function isOpaqueFieldPath(path) {
|
|
128
|
+
const clean = path.replace(/\[\]$/g, '');
|
|
129
|
+
const field = clean.split('.').at(-1);
|
|
130
|
+
return field ? opaqueFieldNames.has(field) || field.endsWith('Ref') || field.endsWith('Refs') : false;
|
|
312
131
|
}
|
|
313
|
-
|
|
314
|
-
|
|
132
|
+
const scan = (v, path = '$')=>typeof v === 'string' ? hasSensitiveText(v, {
|
|
133
|
+
allowOpaqueNumericId: isOpaqueFieldPath(path)
|
|
134
|
+
}) : Array.isArray(v) ? v.some((child)=>scan(child, `${path}[]`)) : !!v && typeof v === 'object' ? Object.entries(v).some(([k, x])=>/cost|price/i.test(k) ? /supplier|internal|wholesale/i.test(String(x)) : scan(x, `${path}.${k}`)) : false;
|
|
135
|
+
export function isSafeBookingRequestKey(value) {
|
|
136
|
+
return safeRequestKey(value);
|
|
315
137
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
return errors.length === 0 ? {
|
|
319
|
-
ok: true
|
|
320
|
-
} : {
|
|
321
|
-
ok: false,
|
|
322
|
-
errors
|
|
323
|
-
};
|
|
138
|
+
function errors(v) {
|
|
139
|
+
return (v.errors ?? []).map((e)=>`${e.instancePath || '$'}: ${e.message ?? e.keyword}`);
|
|
324
140
|
}
|
|
325
|
-
function
|
|
326
|
-
const
|
|
327
|
-
if (!
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
'undoToken'
|
|
340
|
-
], [
|
|
341
|
-
'schemaVersion',
|
|
342
|
-
'kind',
|
|
343
|
-
'actionId',
|
|
344
|
-
'contextRef',
|
|
345
|
-
'status',
|
|
346
|
-
'revision',
|
|
347
|
-
'observation',
|
|
348
|
-
'resultContract'
|
|
349
|
-
], at, errors);
|
|
350
|
-
if (value.schemaVersion !== 'booking.surface.v1') errors.push(`${at}.schemaVersion: booking.surface.v1 required`);
|
|
351
|
-
if (value.kind !== 'action.receipt') errors.push(`${at}.kind: action.receipt required`);
|
|
352
|
-
if (!isNonEmptyString(value.actionId)) errors.push(`${at}.actionId: opaque ref required`);
|
|
353
|
-
if (!isNonEmptyString(value.contextRef)) errors.push(`${at}.contextRef: opaque ref required`);
|
|
354
|
-
if (typeof value.status !== 'string' || !RECEIPT_STATUSES.has(value.status)) errors.push(`${at}.status: unregistered receipt status`);
|
|
355
|
-
if (!isRevision(value.revision)) errors.push(`${at}.revision: non-negative integer required`);
|
|
356
|
-
if (value.undoToken !== undefined && !isNonEmptyString(value.undoToken)) errors.push(`${at}.undoToken: opaque ref required`);
|
|
357
|
-
validateActionObservation(value.observation, `${at}.observation`, errors);
|
|
358
|
-
validateResultContract(value.resultContract, `${at}.resultContract`, errors);
|
|
359
|
-
return errors;
|
|
141
|
+
function workspaceErrors(workspace, path) {
|
|
142
|
+
const e = [];
|
|
143
|
+
if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace)) return e;
|
|
144
|
+
const loadedOffers = Array.isArray(workspace.loadedOffers) ? workspace.loadedOffers : [];
|
|
145
|
+
const offerRefs = loadedOffers.map((offer)=>offer?.offerRef).filter((ref)=>typeof ref === 'string');
|
|
146
|
+
const offerVersionRefs = loadedOffers.map((offer)=>offer?.offerVersionRef).filter((ref)=>typeof ref === 'string');
|
|
147
|
+
if (new Set(offerRefs).size !== offerRefs.length) e.push(`${path}.loadedOffers: duplicate offerRef`);
|
|
148
|
+
if (new Set(offerVersionRefs).size !== offerVersionRefs.length) e.push(`${path}.loadedOffers: duplicate offerVersionRef`);
|
|
149
|
+
const verified = workspace.verifiedOffer;
|
|
150
|
+
if (verified !== undefined) {
|
|
151
|
+
if (!loadedOffers.some((offer)=>offer?.offerRef === verified.offerRef && offer?.offerVersionRef === verified.offerVersionRef)) e.push(`${path}.verifiedOffer: loaded offer version required`);
|
|
152
|
+
if (!safeOpaque(verified.verifiedOfferRef) || !isBookingDateTime(verified.expiresAt)) e.push(`${path}.verifiedOffer: safe ref and parseable expiry required`);
|
|
153
|
+
}
|
|
154
|
+
return e;
|
|
360
155
|
}
|
|
361
|
-
function
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
156
|
+
function cross(value) {
|
|
157
|
+
const e = [];
|
|
158
|
+
if (scan(value)) e.push('$: unsafe PII/payment/supplier-cost/credential text');
|
|
159
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return e;
|
|
160
|
+
const x = value;
|
|
161
|
+
const semantic = x.kind === 'action.receipt.continuation' ? x.receipt : x;
|
|
162
|
+
if (x.workspace && x.kind !== 'user.turn.ingress') e.push(...workspaceErrors(x.workspace, '$.workspace'));
|
|
163
|
+
if (x.schemaVersion === 'booking.surface' && Array.isArray(x.loadedOffers)) e.push(...workspaceErrors(x, '$'));
|
|
164
|
+
if (x.kind === 'user.turn.ingress') {
|
|
165
|
+
if (!safeRequestKey(x.requestKey)) e.push('$.requestKey: safe opaque request key required');
|
|
166
|
+
const workspace = x.workspace;
|
|
167
|
+
e.push(...workspaceErrors(workspace, '$.workspace'));
|
|
168
|
+
const visibleHotels = Array.isArray(workspace?.visibleHotels) ? workspace.visibleHotels : [];
|
|
169
|
+
const loadedOffers = Array.isArray(workspace?.loadedOffers) ? workspace.loadedOffers : [];
|
|
170
|
+
if (workspace?.focusedHotelRef !== undefined && !visibleHotels.some((hotel)=>hotel?.hotelRef === workspace.focusedHotelRef)) e.push('$.workspace.focusedHotelRef: visible hotel required');
|
|
171
|
+
const selectedOffer = workspace?.selectedOfferRef === undefined ? undefined : loadedOffers.find((offer)=>offer?.offerRef === workspace.selectedOfferRef);
|
|
172
|
+
if (workspace?.selectedOfferRef !== undefined && !selectedOffer) e.push('$.workspace.selectedOfferRef: loaded offer required');
|
|
173
|
+
if (selectedOffer && !visibleHotels.some((hotel)=>hotel?.hotelRef === selectedOffer.hotelRef)) e.push('$.workspace.selectedOfferRef: offer hotel must be visible');
|
|
365
174
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
'
|
|
371
|
-
|
|
372
|
-
],
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
return;
|
|
378
|
-
case 'results.state':
|
|
379
|
-
exactKeys(value, [
|
|
380
|
-
'kind',
|
|
381
|
-
'matchedHotelRefs',
|
|
382
|
-
'visibleCount'
|
|
383
|
-
], [
|
|
384
|
-
'kind',
|
|
385
|
-
'matchedHotelRefs',
|
|
386
|
-
'visibleCount'
|
|
387
|
-
], at, errors);
|
|
388
|
-
if (!isStringArray(value.matchedHotelRefs)) errors.push(`${at}.matchedHotelRefs: opaque-ref array required`);
|
|
389
|
-
if (!isCount(value.visibleCount)) errors.push(`${at}.visibleCount: non-negative integer required`);
|
|
390
|
-
return;
|
|
391
|
-
case 'hotel.focus':
|
|
392
|
-
exactKeys(value, [
|
|
393
|
-
'kind',
|
|
394
|
-
'hotelRef'
|
|
395
|
-
], [
|
|
396
|
-
'kind',
|
|
175
|
+
if (semantic !== x && semantic?.resultContract?.hardCriteriaMet === true && semantic.resultContract.blockers?.length) e.push('$.receipt.resultContract: hardCriteriaMet cannot coexist with blockers');
|
|
176
|
+
if (kinds.has(x.kind)) {
|
|
177
|
+
const allowed = {
|
|
178
|
+
'search.patch': [
|
|
179
|
+
'patch'
|
|
180
|
+
],
|
|
181
|
+
'search.run': [],
|
|
182
|
+
'results.view.patch': [
|
|
183
|
+
'patch'
|
|
184
|
+
],
|
|
185
|
+
'hotel.focus': [
|
|
397
186
|
'hotelRef'
|
|
398
|
-
],
|
|
399
|
-
|
|
400
|
-
return;
|
|
401
|
-
case 'hotel.selection':
|
|
402
|
-
exactKeys(value, [
|
|
403
|
-
'kind',
|
|
187
|
+
],
|
|
188
|
+
'hotel.select': [
|
|
404
189
|
'hotelRef'
|
|
405
|
-
],
|
|
406
|
-
|
|
407
|
-
'hotelRef'
|
|
408
|
-
], at, errors);
|
|
409
|
-
if (!isNonEmptyString(value.hotelRef)) errors.push(`${at}.hotelRef: opaque ref required`);
|
|
410
|
-
return;
|
|
411
|
-
case 'offers.state':
|
|
412
|
-
exactKeys(value, [
|
|
413
|
-
'kind',
|
|
414
|
-
'hotelRefs',
|
|
415
|
-
'offerRefs',
|
|
416
|
-
'loadedHotelCount'
|
|
417
|
-
], [
|
|
418
|
-
'kind',
|
|
190
|
+
],
|
|
191
|
+
'offers.query': [
|
|
419
192
|
'hotelRefs',
|
|
193
|
+
'criteria'
|
|
194
|
+
],
|
|
195
|
+
'offers.view.patch': [
|
|
196
|
+
'hotelRef',
|
|
197
|
+
'criteria'
|
|
198
|
+
],
|
|
199
|
+
'offers.compare': [
|
|
420
200
|
'offerRefs',
|
|
421
|
-
'
|
|
422
|
-
],
|
|
423
|
-
|
|
424
|
-
if (!isStringArray(value.offerRefs)) errors.push(`${at}.offerRefs: opaque-ref array required`);
|
|
425
|
-
if (!isCount(value.loadedHotelCount)) errors.push(`${at}.loadedHotelCount: non-negative integer required`);
|
|
426
|
-
return;
|
|
427
|
-
case 'offer.selection':
|
|
428
|
-
exactKeys(value, [
|
|
429
|
-
'kind',
|
|
430
|
-
'offerRef'
|
|
431
|
-
], [
|
|
432
|
-
'kind',
|
|
433
|
-
'offerRef'
|
|
434
|
-
], at, errors);
|
|
435
|
-
if (!isNonEmptyString(value.offerRef)) errors.push(`${at}.offerRef: opaque ref required`);
|
|
436
|
-
return;
|
|
437
|
-
case 'offer.availability':
|
|
438
|
-
exactKeys(value, [
|
|
439
|
-
'kind',
|
|
440
|
-
'offerRef',
|
|
441
|
-
'verifiedOfferRef',
|
|
442
|
-
'available',
|
|
443
|
-
'changedFactRefs'
|
|
444
|
-
], [
|
|
445
|
-
'kind',
|
|
201
|
+
'requestedCount'
|
|
202
|
+
],
|
|
203
|
+
'offer.select': [
|
|
446
204
|
'offerRef',
|
|
447
|
-
'
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (!isNonEmptyString(value.offerRef)) errors.push(`${at}.offerRef: opaque ref required`);
|
|
451
|
-
if (value.verifiedOfferRef !== undefined && !isNonEmptyString(value.verifiedOfferRef)) errors.push(`${at}.verifiedOfferRef: opaque ref required`);
|
|
452
|
-
if (typeof value.available !== 'boolean') errors.push(`${at}.available: boolean required`);
|
|
453
|
-
if (!isStringArray(value.changedFactRefs)) errors.push(`${at}.changedFactRefs: opaque-ref array required`);
|
|
454
|
-
return;
|
|
455
|
-
case 'checkout.handoff':
|
|
456
|
-
exactKeys(value, [
|
|
457
|
-
'kind',
|
|
205
|
+
'offerVersionRef'
|
|
206
|
+
],
|
|
207
|
+
'offer.check': [
|
|
458
208
|
'offerRef',
|
|
459
|
-
'
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
'kind',
|
|
209
|
+
'offerVersionRef'
|
|
210
|
+
],
|
|
211
|
+
'checkout.prepare': [
|
|
463
212
|
'offerRef',
|
|
464
|
-
'
|
|
465
|
-
'
|
|
466
|
-
],
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
exactKeys(value, [
|
|
474
|
-
'kind',
|
|
475
|
-
'orderRef',
|
|
476
|
-
'state'
|
|
477
|
-
], [
|
|
478
|
-
'kind',
|
|
479
|
-
'orderRef',
|
|
480
|
-
'state'
|
|
481
|
-
], at, errors);
|
|
482
|
-
const states = new Set([
|
|
483
|
-
'pending',
|
|
484
|
-
'verified',
|
|
485
|
-
'failed',
|
|
486
|
-
'unknown'
|
|
487
|
-
]);
|
|
488
|
-
if (!isNonEmptyString(value.orderRef)) errors.push(`${at}.orderRef: opaque ref required`);
|
|
489
|
-
if (typeof value.state !== 'string' || !states.has(value.state)) errors.push(`${at}.state: unregistered order state`);
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
case 'gap':
|
|
493
|
-
exactKeys(value, [
|
|
494
|
-
'kind',
|
|
495
|
-
'code',
|
|
496
|
-
'factRefs'
|
|
497
|
-
], [
|
|
498
|
-
'kind',
|
|
499
|
-
'code',
|
|
500
|
-
'factRefs'
|
|
501
|
-
], at, errors);
|
|
502
|
-
if (!isSafeCode(value.code)) errors.push(`${at}.code: safe identifier required`);
|
|
503
|
-
if (!isStringArray(value.factRefs)) errors.push(`${at}.factRefs: opaque-ref array required`);
|
|
504
|
-
return;
|
|
505
|
-
default:
|
|
506
|
-
errors.push(`${at}.kind: unregistered observation kind`);
|
|
213
|
+
'offerVersionRef',
|
|
214
|
+
'verifiedOfferRef'
|
|
215
|
+
],
|
|
216
|
+
'order.observe': [
|
|
217
|
+
'orderRef'
|
|
218
|
+
]
|
|
219
|
+
};
|
|
220
|
+
const input = x.input;
|
|
221
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some((k)=>!allowed[x.kind].includes(k))) e.push('$.input: closed kind-specific input required');
|
|
507
222
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (
|
|
511
|
-
|
|
512
|
-
|
|
223
|
+
if (x.kind === 'action.receipt' && x.resultContract?.hardCriteriaMet === true && x.resultContract.blockers?.length) e.push('$.resultContract: hardCriteriaMet cannot coexist with blockers');
|
|
224
|
+
if (x.kind === 'user.turn' && Array.isArray(x.request?.approval)) e.push('$.request.approval: at most one approval');
|
|
225
|
+
if (x.kind === 'action.receipt' || x.kind === 'action.receipt.continuation') {
|
|
226
|
+
const r = x.kind === 'action.receipt.continuation' ? x.receipt : x;
|
|
227
|
+
if (r?.resultContract?.gapCodes?.some((code)=>!gapCodes.has(String(code)))) e.push('$.resultContract.gapCodes: unregistered gap code');
|
|
228
|
+
const observation = r?.observation;
|
|
229
|
+
if (observation?.kind === 'offer.availability') {
|
|
230
|
+
if (r.status === 'applied' && (observation.available !== true || !observation.verifiedOfferRef || observation.currentOfferVersionRef !== observation.checkedOfferVersionRef || observation.changedFactRefs?.length || observation.gapCodes?.length)) e.push('$.observation: applied availability requires unchanged checked/current version, verified ref, and no gaps');
|
|
231
|
+
if (r.status === 'applied' && (r.resultContract?.outcome !== 'complete' || r.resultContract?.hardCriteriaMet !== true || r.resultContract?.gapCodes?.length || r.resultContract?.blockers?.length)) e.push('$.resultContract: applied availability requires complete hard-criteria result without gaps or blockers');
|
|
232
|
+
if (r.status === 'changed' && (observation.available !== true || !observation.currentOfferVersionRef || observation.currentOfferVersionRef === observation.checkedOfferVersionRef || !observation.changedFactRefs?.length || observation.verifiedOfferRef)) e.push('$.observation: changed availability requires available replacement current version without verified ref');
|
|
233
|
+
if ((r.status === 'unavailable' || r.status === 'no_match') && (observation.available !== false || observation.currentOfferVersionRef || observation.verifiedOfferRef)) e.push('$.observation: negative availability must not expose current or verified version');
|
|
234
|
+
if (r.status !== 'applied' && observation.verifiedOfferRef) e.push('$.observation: only applied availability may expose verified ref');
|
|
235
|
+
}
|
|
513
236
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
'factRefs',
|
|
525
|
-
'gapCodes'
|
|
526
|
-
], at, errors);
|
|
527
|
-
const outcomes = new Set([
|
|
528
|
-
'complete',
|
|
529
|
-
'partial',
|
|
530
|
-
'empty'
|
|
531
|
-
]);
|
|
532
|
-
if (typeof value.outcome !== 'string' || !outcomes.has(value.outcome)) errors.push(`${at}.outcome: complete, partial, or empty required`);
|
|
533
|
-
if (value.requestedCount !== undefined && !isCount(value.requestedCount)) errors.push(`${at}.requestedCount: non-negative integer required`);
|
|
534
|
-
if (value.actualCount !== undefined && !isCount(value.actualCount)) errors.push(`${at}.actualCount: non-negative integer required`);
|
|
535
|
-
if (typeof value.hardCriteriaMet !== 'boolean') errors.push(`${at}.hardCriteriaMet: boolean required`);
|
|
536
|
-
if (!isStringArray(value.factRefs)) errors.push(`${at}.factRefs: opaque-ref array required`);
|
|
537
|
-
if (!Array.isArray(value.gapCodes) || !value.gapCodes.every(isSafeCode)) errors.push(`${at}.gapCodes: safe identifier array required`);
|
|
538
|
-
}
|
|
539
|
-
export function validateActionReceiptV1(value) {
|
|
540
|
-
const errors = collectReceiptErrors(value, '$');
|
|
541
|
-
if (receiptContainsUnsafeOpaqueText(value)) errors.push('$: receipt contains unsafe non-opaque text');
|
|
542
|
-
if (!validateCanonicalReceipt(value)) {
|
|
543
|
-
for (const error of validateCanonicalReceipt.errors ?? [])errors.push(canonicalError(error, '$'));
|
|
237
|
+
const blocker = x.blocker ?? x.question?.blocker;
|
|
238
|
+
if (blocker && !/^(searchDraft|results|offers|checkout)(\.[A-Za-z][A-Za-z0-9_]*){0,3}$/.test(blocker.criterionPath ?? '')) e.push('$.blocker.criterionPath: closed criterion path required');
|
|
239
|
+
if (blocker?.evidence?.factRefs && new Set(blocker.evidence.factRefs).size !== blocker.evidence.factRefs.length) e.push('$.blocker.evidence.factRefs: unique refs required');
|
|
240
|
+
if (x.kind === 'question' && blocker && Array.isArray(x.question?.approvalOptions)) {
|
|
241
|
+
for (const [i, option] of x.question.approvalOptions.entries()){
|
|
242
|
+
const result = validateApprovalAgainstBlocker(option?.approval, blocker);
|
|
243
|
+
if (!result.ok) e.push(...result.errors.map((error)=>`$.question.approvalOptions[${i}].${error.slice(2)}`));
|
|
244
|
+
if (option?.approval?.taskId !== x.taskId) e.push(`$.question.approvalOptions[${i}].approval.taskId: must match event`);
|
|
245
|
+
if (option?.approval?.contextRef !== x.contextRef) e.push(`$.question.approvalOptions[${i}].approval.contextRef: must match event`);
|
|
246
|
+
}
|
|
544
247
|
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
248
|
+
if (kinds.has(String(x.kind)) && x.relaxationApprovalRef) {
|
|
249
|
+
const ref = x.relaxationApprovalRef;
|
|
250
|
+
if (ref.contextRef !== x.contextRef) e.push('$.relaxationApprovalRef.contextRef: must match action');
|
|
251
|
+
if (ref.sourceRevision !== x.expectedRevision) e.push('$.relaxationApprovalRef.sourceRevision: must equal action revision');
|
|
252
|
+
if (ref.targetActionKind !== x.kind) e.push('$.relaxationApprovalRef.targetActionKind: must match action kind');
|
|
253
|
+
if (ref.targetActionId !== x.actionId) e.push('$.relaxationApprovalRef.targetActionId: must match action id');
|
|
254
|
+
if (!safeOpaque(ref.nonce) || !isBookingDateTime(ref.expiresAt)) e.push('$.relaxationApprovalRef: invalid expiry or nonce');
|
|
255
|
+
}
|
|
256
|
+
if (x.kind === 'question' && (!Array.isArray(x.question?.approvalOptions) || x.question.approvalOptions.length < 1 || x.question.approvalOptions.length > 2)) e.push('$.question.approvalOptions: one or two options required');
|
|
257
|
+
return e;
|
|
551
258
|
}
|
|
552
|
-
export function
|
|
553
|
-
const
|
|
554
|
-
|
|
259
|
+
export function validateBookingSurface(value) {
|
|
260
|
+
const ok = validate(value);
|
|
261
|
+
const e = ok ? [] : errors(validate);
|
|
262
|
+
e.push(...cross(value));
|
|
263
|
+
return e.length ? {
|
|
555
264
|
ok: false,
|
|
556
|
-
errors:
|
|
557
|
-
'$: object required'
|
|
558
|
-
]
|
|
559
|
-
};
|
|
560
|
-
exactKeys(value, [
|
|
561
|
-
'schemaVersion',
|
|
562
|
-
'kind',
|
|
563
|
-
'taskId',
|
|
564
|
-
'contextRef',
|
|
565
|
-
'surfaceHint',
|
|
566
|
-
'workspace',
|
|
567
|
-
'request'
|
|
568
|
-
], [
|
|
569
|
-
'schemaVersion',
|
|
570
|
-
'kind',
|
|
571
|
-
'surfaceHint',
|
|
572
|
-
'workspace',
|
|
573
|
-
'request'
|
|
574
|
-
], '$', errors);
|
|
575
|
-
if (value.schemaVersion !== 'booking.surface.v1') errors.push('$.schemaVersion: booking.surface.v1 required');
|
|
576
|
-
if (value.kind !== 'user.turn.ingress') errors.push('$.kind: user.turn.ingress required');
|
|
577
|
-
if (value.contextRef !== undefined && value.contextRef !== null) errors.push('$.contextRef: bootstrap may only omit it or send null');
|
|
578
|
-
if (typeof value.surfaceHint !== 'string' || !SURFACES.has(value.surfaceHint)) errors.push('$.surfaceHint: unsupported surface hint');
|
|
579
|
-
validateIngressWorkspace(value.workspace, '$.workspace', errors);
|
|
580
|
-
if (!isRecord(value.request)) {
|
|
581
|
-
errors.push('$.request: object required');
|
|
582
|
-
} else {
|
|
583
|
-
exactKeys(value.request, [
|
|
584
|
-
'text'
|
|
585
|
-
], [
|
|
586
|
-
'text'
|
|
587
|
-
], '$.request', errors);
|
|
588
|
-
if (!isNonEmptyString(value.request.text)) errors.push('$.request.text: non-empty string required');
|
|
589
|
-
}
|
|
590
|
-
appendCanonicalErrors(validateCanonicalIngressTurn, value, '$', errors);
|
|
591
|
-
return errors.length === 0 ? {
|
|
592
|
-
ok: true
|
|
265
|
+
errors: e
|
|
593
266
|
} : {
|
|
594
|
-
ok:
|
|
595
|
-
errors
|
|
267
|
+
ok: true
|
|
596
268
|
};
|
|
597
269
|
}
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
|
|
270
|
+
function fromCanonical(fn, value) {
|
|
271
|
+
const ok = fn(value);
|
|
272
|
+
const e = ok ? [] : errors(fn);
|
|
273
|
+
e.push(...cross(value));
|
|
274
|
+
return e.length ? {
|
|
601
275
|
ok: false,
|
|
602
|
-
errors:
|
|
603
|
-
'$: object required'
|
|
604
|
-
]
|
|
605
|
-
};
|
|
606
|
-
if (value.kind === 'user.turn') {
|
|
607
|
-
exactKeys(value, [
|
|
608
|
-
'schemaVersion',
|
|
609
|
-
'kind',
|
|
610
|
-
'taskId',
|
|
611
|
-
'workspace',
|
|
612
|
-
'request'
|
|
613
|
-
], [
|
|
614
|
-
'schemaVersion',
|
|
615
|
-
'kind',
|
|
616
|
-
'workspace',
|
|
617
|
-
'request'
|
|
618
|
-
], '$', errors);
|
|
619
|
-
if (!isRecord(value.request)) errors.push('$.request: object required');
|
|
620
|
-
else {
|
|
621
|
-
exactKeys(value.request, [
|
|
622
|
-
'text'
|
|
623
|
-
], [
|
|
624
|
-
'text'
|
|
625
|
-
], '$.request', errors);
|
|
626
|
-
if (!isNonEmptyString(value.request.text)) errors.push('$.request.text: non-empty string required');
|
|
627
|
-
}
|
|
628
|
-
} else if (value.kind === 'action.receipt.continuation') {
|
|
629
|
-
exactKeys(value, [
|
|
630
|
-
'schemaVersion',
|
|
631
|
-
'kind',
|
|
632
|
-
'taskId',
|
|
633
|
-
'workspace',
|
|
634
|
-
'receipt'
|
|
635
|
-
], [
|
|
636
|
-
'schemaVersion',
|
|
637
|
-
'kind',
|
|
638
|
-
'taskId',
|
|
639
|
-
'workspace',
|
|
640
|
-
'receipt'
|
|
641
|
-
], '$', errors);
|
|
642
|
-
if (!isNonEmptyString(value.taskId)) errors.push('$.taskId: opaque ref required');
|
|
643
|
-
errors.push(...collectReceiptErrors(value.receipt, '$.receipt'));
|
|
644
|
-
const receiptValidation = validateActionReceiptV1(value.receipt);
|
|
645
|
-
if (!receiptValidation.ok) errors.push(...receiptValidation.errors.map((error)=>`$.receipt${error.slice(1)}`));
|
|
646
|
-
} else {
|
|
647
|
-
errors.push('$.kind: user.turn or action.receipt.continuation required');
|
|
648
|
-
}
|
|
649
|
-
if (value.schemaVersion !== 'booking.surface.v1') errors.push('$.schemaVersion: booking.surface.v1 required');
|
|
650
|
-
validateWorkspace(value.workspace, '$.workspace', errors);
|
|
651
|
-
if (value.kind === 'action.receipt.continuation' && isRecord(value.workspace) && isRecord(value.receipt)) {
|
|
652
|
-
if (value.receipt.contextRef !== value.workspace.contextRef) errors.push('$.receipt.contextRef: must equal workspace.contextRef');
|
|
653
|
-
if (value.receipt.revision !== value.workspace.revision) errors.push('$.receipt.revision: must equal workspace.revision');
|
|
654
|
-
}
|
|
655
|
-
appendCanonicalErrors(validateCanonicalTurn, value, '$', errors);
|
|
656
|
-
return errors.length === 0 ? {
|
|
657
|
-
ok: true
|
|
276
|
+
errors: e
|
|
658
277
|
} : {
|
|
659
|
-
ok:
|
|
660
|
-
errors
|
|
278
|
+
ok: true
|
|
661
279
|
};
|
|
662
280
|
}
|
|
663
|
-
export function
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
281
|
+
export function validateBookingReadAction(value) {
|
|
282
|
+
return fromCanonical(validateActionCanonical, value);
|
|
283
|
+
}
|
|
284
|
+
export function validateCriterionBlocker(value) {
|
|
285
|
+
return fromCanonical(validateBlockerCanonical, value);
|
|
286
|
+
}
|
|
287
|
+
export function validateRelaxationApproval(value) {
|
|
288
|
+
return fromCanonical(validateApprovalCanonical, value);
|
|
289
|
+
}
|
|
290
|
+
export function validateBookingSurfaceEvent(value) {
|
|
291
|
+
return fromCanonical(validateEventCanonical, value);
|
|
292
|
+
}
|
|
293
|
+
export function validateApprovalAgainstBlocker(approval, blocker) {
|
|
294
|
+
const a = approval;
|
|
295
|
+
const b = blocker;
|
|
296
|
+
const blockerResult = validateCriterionBlocker(b);
|
|
297
|
+
if (!blockerResult.ok) return blockerResult;
|
|
298
|
+
const approvalResult = validateRelaxationApproval(a);
|
|
299
|
+
if (!approvalResult.ok) return approvalResult;
|
|
300
|
+
const fields = [
|
|
301
|
+
'blockerId',
|
|
302
|
+
'sourceActionId',
|
|
303
|
+
'sourceReceiptDigest',
|
|
304
|
+
'scope',
|
|
305
|
+
'code',
|
|
306
|
+
'criterionPath',
|
|
307
|
+
'valueDigest'
|
|
679
308
|
];
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
question: 'question',
|
|
683
|
-
operation: 'action',
|
|
684
|
-
explanation: 'explanation',
|
|
685
|
-
terminal: 'terminal',
|
|
686
|
-
error: 'error'
|
|
687
|
-
};
|
|
688
|
-
const branchKey = typeof value.kind === 'string' ? branchKeys[value.kind] : undefined;
|
|
689
|
-
exactKeys(value, [
|
|
690
|
-
...common,
|
|
691
|
-
...branchKey ? [
|
|
692
|
-
branchKey
|
|
693
|
-
] : []
|
|
694
|
-
], [
|
|
695
|
-
...common,
|
|
696
|
-
...branchKey ? [
|
|
697
|
-
branchKey
|
|
698
|
-
] : []
|
|
699
|
-
], '$', errors);
|
|
700
|
-
if (value.schemaVersion !== 'booking.surface.v1') errors.push('$.schemaVersion: booking.surface.v1 required');
|
|
701
|
-
if (!isNonEmptyString(value.eventId)) errors.push('$.eventId: opaque ref required');
|
|
702
|
-
if (!isNonEmptyString(value.taskId)) errors.push('$.taskId: opaque ref required');
|
|
703
|
-
if (!isNonEmptyString(value.contextRef)) errors.push('$.contextRef: server-minted opaque ref required');
|
|
704
|
-
if (!Number.isInteger(value.sequence) || Number(value.sequence) < 1) errors.push('$.sequence: positive integer required');
|
|
705
|
-
if (!isNonEmptyString(value.emittedAt)) errors.push('$.emittedAt: timestamp required');
|
|
706
|
-
if (typeof value.kind !== 'string' || !EVENT_KINDS.has(value.kind)) errors.push('$.kind: unregistered event kind');
|
|
707
|
-
if (value.kind === 'status') {
|
|
708
|
-
const statuses = new Set([
|
|
709
|
-
'submitted',
|
|
710
|
-
'working',
|
|
711
|
-
'waiting_receipt',
|
|
712
|
-
'input_required'
|
|
713
|
-
]);
|
|
714
|
-
if (typeof value.status !== 'string' || !statuses.has(value.status)) errors.push('$.status: unregistered task status');
|
|
715
|
-
}
|
|
716
|
-
if (value.kind === 'question') {
|
|
717
|
-
if (!isRecord(value.question)) {
|
|
718
|
-
errors.push('$.question: typed object required');
|
|
719
|
-
} else {
|
|
720
|
-
exactKeys(value.question, [
|
|
721
|
-
'questionId',
|
|
722
|
-
'prompt',
|
|
723
|
-
'missingFields'
|
|
724
|
-
], [
|
|
725
|
-
'questionId',
|
|
726
|
-
'prompt',
|
|
727
|
-
'missingFields'
|
|
728
|
-
], '$.question', errors);
|
|
729
|
-
if (!isNonEmptyString(value.question.questionId)) errors.push('$.question.questionId: opaque ref required');
|
|
730
|
-
if (!isNonEmptyString(value.question.prompt)) errors.push('$.question.prompt: non-empty string required');
|
|
731
|
-
if (!isStringArray(value.question.missingFields)) errors.push('$.question.missingFields: string array required');
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
if (value.kind === 'operation') errors.push(...collectBookingReadActionErrors(value.action, '$.action'));
|
|
735
|
-
if (value.kind === 'explanation') {
|
|
736
|
-
if (!isRecord(value.explanation)) {
|
|
737
|
-
errors.push('$.explanation: typed object required');
|
|
738
|
-
} else {
|
|
739
|
-
exactKeys(value.explanation, [
|
|
740
|
-
'text',
|
|
741
|
-
'factRefs'
|
|
742
|
-
], [
|
|
743
|
-
'text',
|
|
744
|
-
'factRefs'
|
|
745
|
-
], '$.explanation', errors);
|
|
746
|
-
if (!isNonEmptyString(value.explanation.text)) errors.push('$.explanation.text: non-empty string required');
|
|
747
|
-
if (!isStringArray(value.explanation.factRefs)) errors.push('$.explanation.factRefs: opaque-ref array required');
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
if (value.kind === 'terminal') {
|
|
751
|
-
if (!isRecord(value.terminal)) {
|
|
752
|
-
errors.push('$.terminal: typed object required');
|
|
753
|
-
} else {
|
|
754
|
-
exactKeys(value.terminal, [
|
|
755
|
-
'status',
|
|
756
|
-
'summary',
|
|
757
|
-
'factRefs'
|
|
758
|
-
], [
|
|
759
|
-
'status',
|
|
760
|
-
'summary',
|
|
761
|
-
'factRefs'
|
|
762
|
-
], '$.terminal', errors);
|
|
763
|
-
const statuses = new Set([
|
|
764
|
-
'completed',
|
|
765
|
-
'stopped'
|
|
766
|
-
]);
|
|
767
|
-
if (typeof value.terminal.status !== 'string' || !statuses.has(value.terminal.status)) errors.push('$.terminal.status: completed or stopped required');
|
|
768
|
-
if (!isNonEmptyString(value.terminal.summary)) errors.push('$.terminal.summary: non-empty string required');
|
|
769
|
-
if (!isStringArray(value.terminal.factRefs)) errors.push('$.terminal.factRefs: opaque-ref array required');
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
if (value.kind === 'error') {
|
|
773
|
-
if (!isRecord(value.error)) {
|
|
774
|
-
errors.push('$.error: typed object required');
|
|
775
|
-
} else {
|
|
776
|
-
exactKeys(value.error, [
|
|
777
|
-
'code',
|
|
778
|
-
'message',
|
|
779
|
-
'retryable'
|
|
780
|
-
], [
|
|
781
|
-
'code',
|
|
782
|
-
'message',
|
|
783
|
-
'retryable'
|
|
784
|
-
], '$.error', errors);
|
|
785
|
-
if (!isNonEmptyString(value.error.code)) errors.push('$.error.code: non-empty string required');
|
|
786
|
-
if (!isNonEmptyString(value.error.message)) errors.push('$.error.message: non-empty string required');
|
|
787
|
-
if (typeof value.error.retryable !== 'boolean') errors.push('$.error.retryable: boolean required');
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
return errors.length === 0 ? {
|
|
791
|
-
ok: true
|
|
792
|
-
} : {
|
|
309
|
+
const mismatches = fields.filter((field)=>a[field] !== b?.[field]);
|
|
310
|
+
return mismatches.length ? {
|
|
793
311
|
ok: false,
|
|
794
|
-
errors
|
|
312
|
+
errors: mismatches.map((field)=>`$.${field}: approval does not match blocker`)
|
|
313
|
+
} : {
|
|
314
|
+
ok: true
|
|
795
315
|
};
|
|
796
316
|
}
|
|
797
|
-
export function bookingOperationFromEvent(value) {
|
|
798
|
-
if (!isRecord(value) || value.kind !== 'operation') return null;
|
|
799
|
-
if (!validateBookingSurfaceEventV1(value).ok) return null;
|
|
800
|
-
return value.action;
|
|
801
|
-
}
|
|
802
|
-
void null;
|
|
803
|
-
void null;
|
|
804
|
-
void null;
|
|
805
|
-
void null;
|
|
806
|
-
void null;
|
|
807
317
|
|
|
808
318
|
|
|
809
319
|
//# sourceURL=ts/src/booking-surface/validation.ts
|