@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,453 +1,205 @@
|
|
|
1
|
-
import {
|
|
2
|
-
BOOKING_READ_ACTION_KINDS,
|
|
3
|
-
BOOKING_RECEIPT_STATUSES,
|
|
4
|
-
BOOKING_SURFACES,
|
|
5
|
-
BOOKING_SURFACE_EVENT_KINDS,
|
|
6
|
-
type ActionReceiptV1,
|
|
7
|
-
type BookingCopilotIngressTurnV1,
|
|
8
|
-
type BookingCopilotTurnV1,
|
|
9
|
-
type BookingReadActionKindV1,
|
|
10
|
-
type BookingReadActionV1,
|
|
11
|
-
type BookingSurfaceEventV1,
|
|
12
|
-
type BookingWorkspaceIngressSnapshotV1,
|
|
13
|
-
type BookingWorkspaceSnapshotV1,
|
|
14
|
-
} from './contracts.ts'
|
|
15
|
-
import { Ajv2020, type ErrorObject, type ValidateFunction } from 'ajv/dist/2020.js'
|
|
16
1
|
import { readFileSync } from 'node:fs'
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
})
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
for (const key of required) if (!Object.hasOwn(value, key)) errors.push(`${at}.${key}: required`)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function validateIngressWorkspace(value: unknown, at: string, errors: string[]): value is BookingWorkspaceIngressSnapshotV1 {
|
|
85
|
-
if (!isRecord(value)) { errors.push(`${at}: object required`); return false }
|
|
86
|
-
exactKeys(value, [
|
|
87
|
-
'schemaVersion', 'revision', 'locale', 'currency', 'searchDraft', 'results',
|
|
88
|
-
'visibleHotels', 'loadedOffers', 'focusedHotelRef', 'shortlistedOfferRefs',
|
|
89
|
-
'selectedOfferRef', 'verifiedOfferRef',
|
|
90
|
-
], [
|
|
91
|
-
'schemaVersion', 'revision', 'locale', 'currency', 'searchDraft', 'results',
|
|
92
|
-
'visibleHotels', 'loadedOffers', 'shortlistedOfferRefs',
|
|
93
|
-
], at, errors)
|
|
94
|
-
if (value.schemaVersion !== 'booking.surface.v1') errors.push(`${at}.schemaVersion: booking.surface.v1 required`)
|
|
95
|
-
if (!isRevision(value.revision)) errors.push(`${at}.revision: non-negative integer required`)
|
|
96
|
-
if (!isNonEmptyString(value.locale)) errors.push(`${at}.locale: non-empty string required`)
|
|
97
|
-
if (typeof value.currency !== 'string' || !/^[A-Z]{3}$/.test(value.currency)) errors.push(`${at}.currency: ISO-4217 code required`)
|
|
98
|
-
if (!isRecord(value.searchDraft)) errors.push(`${at}.searchDraft: object required`)
|
|
99
|
-
if (!isRecord(value.results)) errors.push(`${at}.results: object required`)
|
|
100
|
-
if (!Array.isArray(value.visibleHotels)) errors.push(`${at}.visibleHotels: array required`)
|
|
101
|
-
if (!Array.isArray(value.loadedOffers)) errors.push(`${at}.loadedOffers: array required`)
|
|
102
|
-
if (!isStringArray(value.shortlistedOfferRefs)) errors.push(`${at}.shortlistedOfferRefs: opaque-ref array required`)
|
|
103
|
-
return errors.length === 0
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function validateWorkspace(value: unknown, at: string, errors: string[]): value is BookingWorkspaceSnapshotV1 {
|
|
107
|
-
if (!isRecord(value)) { errors.push(`${at}: object required`); return false }
|
|
108
|
-
const {
|
|
109
|
-
contextRef: _contextRef,
|
|
110
|
-
surface: _surface,
|
|
111
|
-
capabilities: _capabilities,
|
|
112
|
-
...ingressShape
|
|
113
|
-
} = value
|
|
114
|
-
validateIngressWorkspace(ingressShape, at, errors)
|
|
115
|
-
const allowed = [
|
|
116
|
-
'schemaVersion', 'contextRef', 'surface', 'revision', 'locale', 'currency',
|
|
117
|
-
'searchDraft', 'results', 'visibleHotels', 'loadedOffers', 'focusedHotelRef',
|
|
118
|
-
'shortlistedOfferRefs', 'selectedOfferRef', 'verifiedOfferRef', 'capabilities',
|
|
119
|
-
]
|
|
120
|
-
exactKeys(value, allowed, [
|
|
121
|
-
'schemaVersion', 'contextRef', 'surface', 'revision', 'locale', 'currency',
|
|
122
|
-
'searchDraft', 'results', 'visibleHotels', 'loadedOffers', 'shortlistedOfferRefs',
|
|
123
|
-
'capabilities',
|
|
124
|
-
], at, errors)
|
|
125
|
-
if (!isNonEmptyString(value.contextRef)) errors.push(`${at}.contextRef: server-minted opaque ref required`)
|
|
126
|
-
if (typeof value.surface !== 'string' || !SURFACES.has(value.surface)) errors.push(`${at}.surface: unsupported surface`)
|
|
127
|
-
if (!isRecord(value.capabilities)) {
|
|
128
|
-
errors.push(`${at}.capabilities: object required`)
|
|
129
|
-
} else {
|
|
130
|
-
exactKeys(value.capabilities, ['surface', 'allowedActions'], ['surface', 'allowedActions'], `${at}.capabilities`, errors)
|
|
131
|
-
if (value.capabilities.surface !== value.surface) errors.push(`${at}.capabilities.surface: must equal workspace.surface`)
|
|
132
|
-
const actionKinds = value.capabilities.allowedActions
|
|
133
|
-
if (!Array.isArray(actionKinds) || !actionKinds.every((kind) => typeof kind === 'string' && ACTION_KINDS.has(kind))) {
|
|
134
|
-
errors.push(`${at}.capabilities.allowedActions: closed action-kind array required`)
|
|
135
|
-
} else if (new Set(actionKinds).size !== actionKinds.length) {
|
|
136
|
-
errors.push(`${at}.capabilities.allowedActions: duplicates forbidden`)
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
appendCanonicalErrors(validateCanonicalWorkspace, value, at, errors)
|
|
140
|
-
return errors.length === 0
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const ACTION_INPUT_KEYS: Record<BookingReadActionKindV1, { allowed: readonly string[]; required: readonly string[] }> = {
|
|
144
|
-
'search.patch': { allowed: ['patch'], required: ['patch'] },
|
|
145
|
-
'search.run': { allowed: [], required: [] },
|
|
146
|
-
'results.view.patch': { allowed: ['patch'], required: ['patch'] },
|
|
147
|
-
'hotel.focus': { allowed: ['hotelRef'], required: ['hotelRef'] },
|
|
148
|
-
'hotel.select': { allowed: ['hotelRef'], required: ['hotelRef'] },
|
|
149
|
-
'offers.query': { allowed: ['hotelRefs', 'criteria'], required: ['hotelRefs', 'criteria'] },
|
|
150
|
-
'offers.view.patch': { allowed: ['hotelRef', 'criteria'], required: ['hotelRef', 'criteria'] },
|
|
151
|
-
'offers.compare': { allowed: ['offerRefs', 'requestedCount'], required: ['offerRefs', 'requestedCount'] },
|
|
152
|
-
'offer.select': { allowed: ['offerRef'], required: ['offerRef'] },
|
|
153
|
-
'offer.check': { allowed: ['offerRef'], required: ['offerRef'] },
|
|
154
|
-
'checkout.prepare': { allowed: ['offerRef', 'verifiedOfferRef'], required: ['offerRef', 'verifiedOfferRef'] },
|
|
155
|
-
'order.observe': { allowed: ['orderRef'], required: ['orderRef'] },
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function collectBookingReadActionErrors(value: unknown, at: string): string[] {
|
|
159
|
-
const errors: string[] = []
|
|
160
|
-
if (!isRecord(value)) return [`${at}: typed action object required; text is never executable`]
|
|
161
|
-
exactKeys(value, [
|
|
162
|
-
'schemaVersion', 'kind', 'actionId', 'contextRef', 'expectedRevision',
|
|
163
|
-
'reason', 'factRefs', 'input',
|
|
164
|
-
], [
|
|
165
|
-
'schemaVersion', 'kind', 'actionId', 'contextRef', 'expectedRevision',
|
|
166
|
-
'reason', 'factRefs', 'input',
|
|
167
|
-
], at, errors)
|
|
168
|
-
if (value.schemaVersion !== 'booking.surface.v1') errors.push(`${at}.schemaVersion: booking.surface.v1 required`)
|
|
169
|
-
if (typeof value.kind !== 'string' || !ACTION_KINDS.has(value.kind)) {
|
|
170
|
-
errors.push(`${at}.kind: unregistered read action`)
|
|
171
|
-
}
|
|
172
|
-
if (!isNonEmptyString(value.actionId)) errors.push(`${at}.actionId: opaque ref required`)
|
|
173
|
-
if (!isNonEmptyString(value.contextRef)) errors.push(`${at}.contextRef: opaque ref required`)
|
|
174
|
-
if (!isRevision(value.expectedRevision)) errors.push(`${at}.expectedRevision: non-negative integer required`)
|
|
175
|
-
if (!isNonEmptyString(value.reason)) errors.push(`${at}.reason: non-empty explanation required`)
|
|
176
|
-
if (!isStringArray(value.factRefs)) errors.push(`${at}.factRefs: opaque-ref array required`)
|
|
177
|
-
if (!isRecord(value.input)) {
|
|
178
|
-
errors.push(`${at}.input: typed object required`)
|
|
179
|
-
} else if (typeof value.kind === 'string' && ACTION_KINDS.has(value.kind)) {
|
|
180
|
-
const spec = ACTION_INPUT_KEYS[value.kind as BookingReadActionKindV1]
|
|
181
|
-
exactKeys(value.input, spec.allowed, spec.required, `${at}.input`, errors)
|
|
182
|
-
}
|
|
183
|
-
if (!validateCanonicalAction(value)) {
|
|
184
|
-
for (const error of validateCanonicalAction.errors ?? []) {
|
|
185
|
-
errors.push(canonicalError(error, at))
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
return errors
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function canonicalError(error: ErrorObject, at: string): string {
|
|
192
|
-
const path = error.instancePath ? error.instancePath.replaceAll('/', '.') : ''
|
|
193
|
-
const property = error.keyword === 'additionalProperties'
|
|
194
|
-
? `.${String((error.params as { additionalProperty?: unknown }).additionalProperty ?? '')}`
|
|
195
|
-
: ''
|
|
196
|
-
return `${at}${path}${property}: ${error.message ?? error.keyword}`
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function appendCanonicalErrors(validate: ValidateFunction, value: unknown, at: string, errors: string[]): void {
|
|
200
|
-
if (!validate(value)) for (const error of validate.errors ?? []) errors.push(canonicalError(error, at))
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export function validateBookingReadActionV1(value: unknown): BookingSurfaceValidationResult {
|
|
204
|
-
const errors = collectBookingReadActionErrors(value, '$')
|
|
205
|
-
return errors.length === 0 ? { ok: true } : { ok: false, errors }
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function collectReceiptErrors(value: unknown, at: string): string[] {
|
|
209
|
-
const errors: string[] = []
|
|
210
|
-
if (!isRecord(value)) return [`${at}: typed receipt object required`]
|
|
211
|
-
exactKeys(value, [
|
|
212
|
-
'schemaVersion', 'kind', 'actionId', 'contextRef', 'status', 'revision',
|
|
213
|
-
'observation', 'resultContract', 'undoToken',
|
|
214
|
-
], [
|
|
215
|
-
'schemaVersion', 'kind', 'actionId', 'contextRef', 'status', 'revision',
|
|
216
|
-
'observation', 'resultContract',
|
|
217
|
-
], at, errors)
|
|
218
|
-
if (value.schemaVersion !== 'booking.surface.v1') errors.push(`${at}.schemaVersion: booking.surface.v1 required`)
|
|
219
|
-
if (value.kind !== 'action.receipt') errors.push(`${at}.kind: action.receipt required`)
|
|
220
|
-
if (!isNonEmptyString(value.actionId)) errors.push(`${at}.actionId: opaque ref required`)
|
|
221
|
-
if (!isNonEmptyString(value.contextRef)) errors.push(`${at}.contextRef: opaque ref required`)
|
|
222
|
-
if (typeof value.status !== 'string' || !RECEIPT_STATUSES.has(value.status)) errors.push(`${at}.status: unregistered receipt status`)
|
|
223
|
-
if (!isRevision(value.revision)) errors.push(`${at}.revision: non-negative integer required`)
|
|
224
|
-
if (value.undoToken !== undefined && !isNonEmptyString(value.undoToken)) errors.push(`${at}.undoToken: opaque ref required`)
|
|
225
|
-
validateActionObservation(value.observation, `${at}.observation`, errors)
|
|
226
|
-
validateResultContract(value.resultContract, `${at}.resultContract`, errors)
|
|
227
|
-
return errors
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function validateActionObservation(value: unknown, at: string, errors: string[]): void {
|
|
231
|
-
if (!isRecord(value)) {
|
|
232
|
-
errors.push(`${at}: discriminated object required`)
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
switch (value.kind) {
|
|
237
|
-
case 'search.state':
|
|
238
|
-
exactKeys(value, ['kind', 'searchSessionRef', 'resultCount'], ['kind'], at, errors)
|
|
239
|
-
if (value.searchSessionRef !== undefined && !isNonEmptyString(value.searchSessionRef)) errors.push(`${at}.searchSessionRef: opaque ref required`)
|
|
240
|
-
if (value.resultCount !== undefined && !isCount(value.resultCount)) errors.push(`${at}.resultCount: non-negative integer required`)
|
|
241
|
-
return
|
|
242
|
-
case 'results.state':
|
|
243
|
-
exactKeys(value, ['kind', 'matchedHotelRefs', 'visibleCount'], ['kind', 'matchedHotelRefs', 'visibleCount'], at, errors)
|
|
244
|
-
if (!isStringArray(value.matchedHotelRefs)) errors.push(`${at}.matchedHotelRefs: opaque-ref array required`)
|
|
245
|
-
if (!isCount(value.visibleCount)) errors.push(`${at}.visibleCount: non-negative integer required`)
|
|
246
|
-
return
|
|
247
|
-
case 'hotel.focus':
|
|
248
|
-
exactKeys(value, ['kind', 'hotelRef'], ['kind', 'hotelRef'], at, errors)
|
|
249
|
-
if (!isNonEmptyString(value.hotelRef)) errors.push(`${at}.hotelRef: opaque ref required`)
|
|
250
|
-
return
|
|
251
|
-
case 'hotel.selection':
|
|
252
|
-
exactKeys(value, ['kind', 'hotelRef'], ['kind', 'hotelRef'], at, errors)
|
|
253
|
-
if (!isNonEmptyString(value.hotelRef)) errors.push(`${at}.hotelRef: opaque ref required`)
|
|
254
|
-
return
|
|
255
|
-
case 'offers.state':
|
|
256
|
-
exactKeys(value, ['kind', 'hotelRefs', 'offerRefs', 'loadedHotelCount'], ['kind', 'hotelRefs', 'offerRefs', 'loadedHotelCount'], at, errors)
|
|
257
|
-
if (!isStringArray(value.hotelRefs)) errors.push(`${at}.hotelRefs: opaque-ref array required`)
|
|
258
|
-
if (!isStringArray(value.offerRefs)) errors.push(`${at}.offerRefs: opaque-ref array required`)
|
|
259
|
-
if (!isCount(value.loadedHotelCount)) errors.push(`${at}.loadedHotelCount: non-negative integer required`)
|
|
260
|
-
return
|
|
261
|
-
case 'offer.selection':
|
|
262
|
-
exactKeys(value, ['kind', 'offerRef'], ['kind', 'offerRef'], at, errors)
|
|
263
|
-
if (!isNonEmptyString(value.offerRef)) errors.push(`${at}.offerRef: opaque ref required`)
|
|
264
|
-
return
|
|
265
|
-
case 'offer.availability':
|
|
266
|
-
exactKeys(value, ['kind', 'offerRef', 'verifiedOfferRef', 'available', 'changedFactRefs'], ['kind', 'offerRef', 'available', 'changedFactRefs'], at, errors)
|
|
267
|
-
if (!isNonEmptyString(value.offerRef)) errors.push(`${at}.offerRef: opaque ref required`)
|
|
268
|
-
if (value.verifiedOfferRef !== undefined && !isNonEmptyString(value.verifiedOfferRef)) errors.push(`${at}.verifiedOfferRef: opaque ref required`)
|
|
269
|
-
if (typeof value.available !== 'boolean') errors.push(`${at}.available: boolean required`)
|
|
270
|
-
if (!isStringArray(value.changedFactRefs)) errors.push(`${at}.changedFactRefs: opaque-ref array required`)
|
|
271
|
-
return
|
|
272
|
-
case 'checkout.handoff':
|
|
273
|
-
exactKeys(value, ['kind', 'offerRef', 'verifiedOfferRef', 'handoffRef'], ['kind', 'offerRef', 'verifiedOfferRef', 'handoffRef'], at, errors)
|
|
274
|
-
if (!isNonEmptyString(value.offerRef)) errors.push(`${at}.offerRef: opaque ref required`)
|
|
275
|
-
if (!isNonEmptyString(value.verifiedOfferRef)) errors.push(`${at}.verifiedOfferRef: opaque ref required`)
|
|
276
|
-
if (!isNonEmptyString(value.handoffRef)) errors.push(`${at}.handoffRef: opaque ref required`)
|
|
277
|
-
return
|
|
278
|
-
case 'order.state': {
|
|
279
|
-
exactKeys(value, ['kind', 'orderRef', 'state'], ['kind', 'orderRef', 'state'], at, errors)
|
|
280
|
-
const states = new Set(['pending', 'verified', 'failed', 'unknown'])
|
|
281
|
-
if (!isNonEmptyString(value.orderRef)) errors.push(`${at}.orderRef: opaque ref required`)
|
|
282
|
-
if (typeof value.state !== 'string' || !states.has(value.state)) errors.push(`${at}.state: unregistered order state`)
|
|
283
|
-
return
|
|
2
|
+
import { Ajv2020, type ValidateFunction } from 'ajv/dist/2020.js'
|
|
3
|
+
import { BOOKING_READ_ACTION_KINDS, BOOKING_BLOCKER_CODES, BOOKING_GAP_CODES } from './contracts.ts'
|
|
4
|
+
|
|
5
|
+
const schema = JSON.parse(readFileSync(new URL('../../../schemas/booking.surface.schema.json', import.meta.url), 'utf8')) as Record<string, unknown>
|
|
6
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true })
|
|
7
|
+
ajv.addSchema(schema)
|
|
8
|
+
const validate: ValidateFunction = ajv.compile(schema)
|
|
9
|
+
const validateActionCanonical = ajv.compile({ $ref: `${schema.$id}#/$defs/Action` })
|
|
10
|
+
const validateBlockerCanonical = ajv.compile({ $ref: `${schema.$id}#/$defs/Blocker` })
|
|
11
|
+
const validateApprovalCanonical = ajv.compile({ $ref: `${schema.$id}#/$defs/Approval` })
|
|
12
|
+
const validateEventCanonical = ajv.compile({ $ref: `${schema.$id}#/$defs/Event` })
|
|
13
|
+
const kinds = new Set<string>(BOOKING_READ_ACTION_KINDS)
|
|
14
|
+
const codes = new Set<string>(BOOKING_BLOCKER_CODES)
|
|
15
|
+
const gapCodes = new Set<string>(BOOKING_GAP_CODES)
|
|
16
|
+
const safeOpaque = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(value)
|
|
17
|
+
const safeRequestKey = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9:._-]{7,127}$/.test(value) && !secret.test(value)
|
|
18
|
+
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
|
|
19
|
+
// `.example` is reserved for documentation, so these identifiers cannot be a
|
|
20
|
+
// real mailbox. Do not allowlist lookalikes on routable domains.
|
|
21
|
+
const technicalEmail = /^(?:schema|version)@[a-z0-9-]+\.example$/i
|
|
22
|
+
const currencyAmount = /\b(?:[A-Z]{3}\s*\d+(?:\.\d+)?|\d+(?:\.\d+)?\s*[A-Z]{3})\b/
|
|
23
|
+
const rfc3339DateTime = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/
|
|
24
|
+
|
|
25
|
+
export function isBookingDateTime(value: unknown): value is string {
|
|
26
|
+
if (typeof value !== 'string') return false
|
|
27
|
+
const match = rfc3339DateTime.exec(value)
|
|
28
|
+
if (!match) return false
|
|
29
|
+
const [, yearText, monthText, dayText, hourText, minuteText, secondText] = match
|
|
30
|
+
const year = Number(yearText)
|
|
31
|
+
const month = Number(monthText)
|
|
32
|
+
const day = Number(dayText)
|
|
33
|
+
const hour = Number(hourText)
|
|
34
|
+
const minute = Number(minuteText)
|
|
35
|
+
const second = Number(secondText)
|
|
36
|
+
const offsetHour = match[9] === undefined ? 0 : Number(match[9])
|
|
37
|
+
const offsetMinute = match[10] === undefined ? 0 : Number(match[10])
|
|
38
|
+
if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) return false
|
|
39
|
+
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate()
|
|
40
|
+
if (day < 1 || day > daysInMonth) return false
|
|
41
|
+
return !Number.isNaN(Date.parse(value))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizedWords(value: string): string {
|
|
45
|
+
return value.normalize('NFKC').replace(/[^\p{L}\p{N}.]+/gu, ' ').trim()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function hasSupplierCost(value: string): boolean {
|
|
49
|
+
const normalized = normalizedWords(value)
|
|
50
|
+
const lower = normalized.toLowerCase()
|
|
51
|
+
return (
|
|
52
|
+
/\b(?:supplier|vendor|internal)\b/.test(lower) &&
|
|
53
|
+
/\b(?:cost|rate|price)\b/.test(lower) &&
|
|
54
|
+
currencyAmount.test(normalized.toUpperCase())
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasInternationalPhone(value: string): boolean {
|
|
59
|
+
const normalized = value.normalize('NFKC')
|
|
60
|
+
for (const plusIndex of normalized.split('').flatMap((char, index) => char === '+' ? [index] : [])) {
|
|
61
|
+
let digits = ''
|
|
62
|
+
for (const char of normalized.slice(plusIndex + 1, plusIndex + 48)) {
|
|
63
|
+
if (/\d/u.test(char)) digits += char
|
|
64
|
+
else if (/\p{L}|@/u.test(char)) break
|
|
65
|
+
if (digits.length > 15) break
|
|
284
66
|
}
|
|
285
|
-
|
|
286
|
-
exactKeys(value, ['kind', 'code', 'factRefs'], ['kind', 'code', 'factRefs'], at, errors)
|
|
287
|
-
if (!isSafeCode(value.code)) errors.push(`${at}.code: safe identifier required`)
|
|
288
|
-
if (!isStringArray(value.factRefs)) errors.push(`${at}.factRefs: opaque-ref array required`)
|
|
289
|
-
return
|
|
290
|
-
default:
|
|
291
|
-
errors.push(`${at}.kind: unregistered observation kind`)
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function validateResultContract(value: unknown, at: string, errors: string[]): void {
|
|
296
|
-
if (!isRecord(value)) {
|
|
297
|
-
errors.push(`${at}: object required`)
|
|
298
|
-
return
|
|
67
|
+
if (/^[1-9]\d{7,14}$/.test(digits)) return true
|
|
299
68
|
}
|
|
300
|
-
|
|
301
|
-
const outcomes = new Set(['complete', 'partial', 'empty'])
|
|
302
|
-
if (typeof value.outcome !== 'string' || !outcomes.has(value.outcome)) errors.push(`${at}.outcome: complete, partial, or empty required`)
|
|
303
|
-
if (value.requestedCount !== undefined && !isCount(value.requestedCount)) errors.push(`${at}.requestedCount: non-negative integer required`)
|
|
304
|
-
if (value.actualCount !== undefined && !isCount(value.actualCount)) errors.push(`${at}.actualCount: non-negative integer required`)
|
|
305
|
-
if (typeof value.hardCriteriaMet !== 'boolean') errors.push(`${at}.hardCriteriaMet: boolean required`)
|
|
306
|
-
if (!isStringArray(value.factRefs)) errors.push(`${at}.factRefs: opaque-ref array required`)
|
|
307
|
-
if (!Array.isArray(value.gapCodes) || !value.gapCodes.every(isSafeCode)) errors.push(`${at}.gapCodes: safe identifier array required`)
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export function validateActionReceiptV1(value: unknown): BookingSurfaceValidationResult {
|
|
311
|
-
const errors = collectReceiptErrors(value, '$')
|
|
312
|
-
if (receiptContainsUnsafeOpaqueText(value)) errors.push('$: receipt contains unsafe non-opaque text')
|
|
313
|
-
if (!validateCanonicalReceipt(value)) {
|
|
314
|
-
for (const error of validateCanonicalReceipt.errors ?? []) errors.push(canonicalError(error, '$'))
|
|
315
|
-
}
|
|
316
|
-
return errors.length === 0 ? { ok: true } : { ok: false, errors }
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
export function validateBookingCopilotIngressTurnV1(value: unknown): BookingSurfaceValidationResult {
|
|
320
|
-
const errors: string[] = []
|
|
321
|
-
if (!isRecord(value)) return { ok: false, errors: ['$: object required'] }
|
|
322
|
-
exactKeys(value, [
|
|
323
|
-
'schemaVersion', 'kind', 'taskId', 'contextRef', 'surfaceHint', 'workspace', 'request',
|
|
324
|
-
], ['schemaVersion', 'kind', 'surfaceHint', 'workspace', 'request'], '$', errors)
|
|
325
|
-
if (value.schemaVersion !== 'booking.surface.v1') errors.push('$.schemaVersion: booking.surface.v1 required')
|
|
326
|
-
if (value.kind !== 'user.turn.ingress') errors.push('$.kind: user.turn.ingress required')
|
|
327
|
-
if (value.contextRef !== undefined && value.contextRef !== null) errors.push('$.contextRef: bootstrap may only omit it or send null')
|
|
328
|
-
if (typeof value.surfaceHint !== 'string' || !SURFACES.has(value.surfaceHint)) errors.push('$.surfaceHint: unsupported surface hint')
|
|
329
|
-
validateIngressWorkspace(value.workspace, '$.workspace', errors)
|
|
330
|
-
if (!isRecord(value.request)) {
|
|
331
|
-
errors.push('$.request: object required')
|
|
332
|
-
} else {
|
|
333
|
-
exactKeys(value.request, ['text'], ['text'], '$.request', errors)
|
|
334
|
-
if (!isNonEmptyString(value.request.text)) errors.push('$.request.text: non-empty string required')
|
|
335
|
-
}
|
|
336
|
-
appendCanonicalErrors(validateCanonicalIngressTurn, value, '$', errors)
|
|
337
|
-
return errors.length === 0 ? { ok: true } : { ok: false, errors }
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
export function validateBookingCopilotTurnV1(value: unknown): BookingSurfaceValidationResult {
|
|
341
|
-
const errors: string[] = []
|
|
342
|
-
if (!isRecord(value)) return { ok: false, errors: ['$: object required'] }
|
|
343
|
-
if (value.kind === 'user.turn') {
|
|
344
|
-
exactKeys(value, ['schemaVersion', 'kind', 'taskId', 'workspace', 'request'], ['schemaVersion', 'kind', 'workspace', 'request'], '$', errors)
|
|
345
|
-
if (!isRecord(value.request)) errors.push('$.request: object required')
|
|
346
|
-
else {
|
|
347
|
-
exactKeys(value.request, ['text'], ['text'], '$.request', errors)
|
|
348
|
-
if (!isNonEmptyString(value.request.text)) errors.push('$.request.text: non-empty string required')
|
|
349
|
-
}
|
|
350
|
-
} else if (value.kind === 'action.receipt.continuation') {
|
|
351
|
-
exactKeys(value, ['schemaVersion', 'kind', 'taskId', 'workspace', 'receipt'], ['schemaVersion', 'kind', 'taskId', 'workspace', 'receipt'], '$', errors)
|
|
352
|
-
if (!isNonEmptyString(value.taskId)) errors.push('$.taskId: opaque ref required')
|
|
353
|
-
errors.push(...collectReceiptErrors(value.receipt, '$.receipt'))
|
|
354
|
-
const receiptValidation = validateActionReceiptV1(value.receipt)
|
|
355
|
-
if (!receiptValidation.ok) errors.push(...receiptValidation.errors.map((error) => `$.receipt${error.slice(1)}`))
|
|
356
|
-
} else {
|
|
357
|
-
errors.push('$.kind: user.turn or action.receipt.continuation required')
|
|
358
|
-
}
|
|
359
|
-
if (value.schemaVersion !== 'booking.surface.v1') errors.push('$.schemaVersion: booking.surface.v1 required')
|
|
360
|
-
validateWorkspace(value.workspace, '$.workspace', errors)
|
|
361
|
-
if (value.kind === 'action.receipt.continuation' && isRecord(value.workspace) && isRecord(value.receipt)) {
|
|
362
|
-
if (value.receipt.contextRef !== value.workspace.contextRef) errors.push('$.receipt.contextRef: must equal workspace.contextRef')
|
|
363
|
-
if (value.receipt.revision !== value.workspace.revision) errors.push('$.receipt.revision: must equal workspace.revision')
|
|
364
|
-
}
|
|
365
|
-
appendCanonicalErrors(validateCanonicalTurn, value, '$', errors)
|
|
366
|
-
return errors.length === 0 ? { ok: true } : { ok: false, errors }
|
|
69
|
+
return false
|
|
367
70
|
}
|
|
368
71
|
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
72
|
+
function hasPaymentCard(value: string): boolean {
|
|
73
|
+
const normalized = value.normalize('NFKC')
|
|
74
|
+
const candidates = normalized.match(/(?:\d[ -]?){12,18}\d/g) ?? []
|
|
75
|
+
const explicitlyLabeled = /\b(?:card|payment|visa|mastercard)\b/i.test(normalized)
|
|
76
|
+
return candidates.some((candidate) => (explicitlyLabeled || /[ -]/.test(candidate)) && luhn(candidate))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function hasSensitiveText(value: string, opts: { allowOpaqueNumericId?: boolean } = {}): boolean {
|
|
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))
|
|
83
|
+
}
|
|
84
|
+
function luhn(value: string): boolean { const digits = value.replaceAll(/\D/g, ''); if (digits.length < 13 || digits.length > 19) return false; let sum = 0; for (let i = digits.length - 1, parity = 0; i >= 0; i--, parity++) { let n = Number(digits[i]); if (parity % 2 === 1) { n *= 2; if (n > 9) n -= 9 } sum += n } return sum % 10 === 0 }
|
|
85
|
+
const opaqueFieldNames = new Set([
|
|
86
|
+
'eventId', 'taskId', 'contextRef', 'actionId', 'hotelRef', 'offerRef', 'offerVersionRef', 'checkedOfferVersionRef', 'currentOfferVersionRef', 'verifiedOfferRef',
|
|
87
|
+
'orderRef', 'searchSessionRef', 'handoffRef', 'questionId', 'blockerId', 'approvalId',
|
|
88
|
+
'sourceActionId', 'targetActionId', 'sourceTurnId', 'turnId', 'requestKey', 'taskHandle',
|
|
89
|
+
'nonce', 'deliveryNonce', 'optionDigest', 'sourceReceiptDigest', 'valueDigest',
|
|
90
|
+
])
|
|
91
|
+
function isOpaqueFieldPath(path: string): boolean {
|
|
92
|
+
const clean = path.replace(/\[\]$/g, '')
|
|
93
|
+
const field = clean.split('.').at(-1)
|
|
94
|
+
return field ? opaqueFieldNames.has(field) || field.endsWith('Ref') || field.endsWith('Refs') : false
|
|
95
|
+
}
|
|
96
|
+
const scan = (v: unknown, path = '$'): boolean => typeof v === 'string'
|
|
97
|
+
? hasSensitiveText(v, { allowOpaqueNumericId: isOpaqueFieldPath(path) })
|
|
98
|
+
: Array.isArray(v)
|
|
99
|
+
? v.some((child) => scan(child, `${path}[]`))
|
|
100
|
+
: !!v && typeof v === 'object'
|
|
101
|
+
? Object.entries(v).some(([k,x]) => /cost|price/i.test(k) ? /supplier|internal|wholesale/i.test(String(x)) : scan(x, `${path}.${k}`))
|
|
102
|
+
: false
|
|
103
|
+
export function isSafeBookingRequestKey(value: unknown): value is string { return safeRequestKey(value) }
|
|
104
|
+
export type BookingSurfaceValidationResult = { ok: true } | { ok: false; errors: string[] }
|
|
105
|
+
function errors(v: ValidateFunction): string[] { return (v.errors ?? []).map(e => `${e.instancePath || '$'}: ${e.message ?? e.keyword}`) }
|
|
106
|
+
function workspaceErrors(workspace: Record<string, any> | undefined, path: string): string[] {
|
|
107
|
+
const e: string[] = []
|
|
108
|
+
if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace)) return e
|
|
109
|
+
const loadedOffers = Array.isArray(workspace.loadedOffers) ? workspace.loadedOffers : []
|
|
110
|
+
const offerRefs = loadedOffers.map((offer: any) => offer?.offerRef).filter((ref: unknown): ref is string => typeof ref === 'string')
|
|
111
|
+
const offerVersionRefs = loadedOffers.map((offer: any) => offer?.offerVersionRef).filter((ref: unknown): ref is string => typeof ref === 'string')
|
|
112
|
+
if (new Set(offerRefs).size !== offerRefs.length) e.push(`${path}.loadedOffers: duplicate offerRef`)
|
|
113
|
+
if (new Set(offerVersionRefs).size !== offerVersionRefs.length) e.push(`${path}.loadedOffers: duplicate offerVersionRef`)
|
|
114
|
+
const verified = workspace.verifiedOffer
|
|
115
|
+
if (verified !== undefined) {
|
|
116
|
+
if (!loadedOffers.some((offer: any) => offer?.offerRef === verified.offerRef && offer?.offerVersionRef === verified.offerVersionRef)) e.push(`${path}.verifiedOffer: loaded offer version required`)
|
|
117
|
+
if (!safeOpaque(verified.verifiedOfferRef) || !isBookingDateTime(verified.expiresAt)) e.push(`${path}.verifiedOffer: safe ref and parseable expiry required`)
|
|
118
|
+
}
|
|
119
|
+
return e
|
|
120
|
+
}
|
|
121
|
+
function cross(value: unknown): string[] {
|
|
122
|
+
const e: string[] = []
|
|
123
|
+
if (scan(value)) e.push('$: unsafe PII/payment/supplier-cost/credential text')
|
|
124
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return e
|
|
125
|
+
const x = value as Record<string, any>
|
|
126
|
+
const semantic = x.kind === 'action.receipt.continuation' ? x.receipt : x
|
|
127
|
+
if (x.workspace && x.kind !== 'user.turn.ingress') e.push(...workspaceErrors(x.workspace, '$.workspace'))
|
|
128
|
+
if (x.schemaVersion === 'booking.surface' && Array.isArray(x.loadedOffers)) e.push(...workspaceErrors(x, '$'))
|
|
129
|
+
if (x.kind === 'user.turn.ingress') {
|
|
130
|
+
if (!safeRequestKey(x.requestKey)) e.push('$.requestKey: safe opaque request key required')
|
|
131
|
+
const workspace = x.workspace as Record<string, any> | undefined
|
|
132
|
+
e.push(...workspaceErrors(workspace, '$.workspace'))
|
|
133
|
+
const visibleHotels = Array.isArray(workspace?.visibleHotels) ? workspace.visibleHotels : []
|
|
134
|
+
const loadedOffers = Array.isArray(workspace?.loadedOffers) ? workspace.loadedOffers : []
|
|
135
|
+
if (workspace?.focusedHotelRef !== undefined && !visibleHotels.some((hotel: any) => hotel?.hotelRef === workspace.focusedHotelRef)) e.push('$.workspace.focusedHotelRef: visible hotel required')
|
|
136
|
+
const selectedOffer = workspace?.selectedOfferRef === undefined ? undefined : loadedOffers.find((offer: any) => offer?.offerRef === workspace.selectedOfferRef)
|
|
137
|
+
if (workspace?.selectedOfferRef !== undefined && !selectedOffer) e.push('$.workspace.selectedOfferRef: loaded offer required')
|
|
138
|
+
if (selectedOffer && !visibleHotels.some((hotel: any) => hotel?.hotelRef === selectedOffer.hotelRef)) e.push('$.workspace.selectedOfferRef: offer hotel must be visible')
|
|
139
|
+
}
|
|
140
|
+
if (semantic !== x && semantic?.resultContract?.hardCriteriaMet === true && semantic.resultContract.blockers?.length) e.push('$.receipt.resultContract: hardCriteriaMet cannot coexist with blockers')
|
|
141
|
+
if (kinds.has(x.kind)) {
|
|
142
|
+
const allowed: Record<string, string[]> = {
|
|
143
|
+
'search.patch': ['patch'], 'search.run': [], 'results.view.patch': ['patch'],
|
|
144
|
+
'hotel.focus': ['hotelRef'], 'hotel.select': ['hotelRef'],
|
|
145
|
+
'offers.query': ['hotelRefs', 'criteria'], 'offers.view.patch': ['hotelRef', 'criteria'],
|
|
146
|
+
'offers.compare': ['offerRefs', 'requestedCount'], 'offer.select': ['offerRef', 'offerVersionRef'],
|
|
147
|
+
'offer.check': ['offerRef', 'offerVersionRef'], 'checkout.prepare': ['offerRef', 'offerVersionRef', 'verifiedOfferRef'], 'order.observe': ['orderRef'],
|
|
412
148
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
149
|
+
const input = x.input
|
|
150
|
+
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')
|
|
151
|
+
}
|
|
152
|
+
if (x.kind === 'action.receipt' && x.resultContract?.hardCriteriaMet === true && x.resultContract.blockers?.length) e.push('$.resultContract: hardCriteriaMet cannot coexist with blockers')
|
|
153
|
+
if (x.kind === 'user.turn' && Array.isArray(x.request?.approval)) e.push('$.request.approval: at most one approval')
|
|
154
|
+
if (x.kind === 'action.receipt' || x.kind === 'action.receipt.continuation') {
|
|
155
|
+
const r = x.kind === 'action.receipt.continuation' ? x.receipt : x
|
|
156
|
+
if (r?.resultContract?.gapCodes?.some((code: unknown) => !gapCodes.has(String(code)))) e.push('$.resultContract.gapCodes: unregistered gap code')
|
|
157
|
+
const observation = r?.observation
|
|
158
|
+
if (observation?.kind === 'offer.availability') {
|
|
159
|
+
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')
|
|
160
|
+
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')
|
|
161
|
+
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')
|
|
162
|
+
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')
|
|
163
|
+
if (r.status !== 'applied' && observation.verifiedOfferRef) e.push('$.observation: only applied availability may expose verified ref')
|
|
423
164
|
}
|
|
424
165
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (!
|
|
432
|
-
if (
|
|
166
|
+
const blocker = x.blocker ?? x.question?.blocker
|
|
167
|
+
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')
|
|
168
|
+
if (blocker?.evidence?.factRefs && new Set(blocker.evidence.factRefs).size !== blocker.evidence.factRefs.length) e.push('$.blocker.evidence.factRefs: unique refs required')
|
|
169
|
+
if (x.kind === 'question' && blocker && Array.isArray(x.question?.approvalOptions)) {
|
|
170
|
+
for (const [i, option] of x.question.approvalOptions.entries()) {
|
|
171
|
+
const result = validateApprovalAgainstBlocker(option?.approval, blocker)
|
|
172
|
+
if (!result.ok) e.push(...result.errors.map(error => `$.question.approvalOptions[${i}].${error.slice(2)}`))
|
|
173
|
+
if (option?.approval?.taskId !== x.taskId) e.push(`$.question.approvalOptions[${i}].approval.taskId: must match event`)
|
|
174
|
+
if (option?.approval?.contextRef !== x.contextRef) e.push(`$.question.approvalOptions[${i}].approval.contextRef: must match event`)
|
|
433
175
|
}
|
|
434
176
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (!
|
|
445
|
-
return
|
|
177
|
+
if (kinds.has(String(x.kind)) && x.relaxationApprovalRef) {
|
|
178
|
+
const ref = x.relaxationApprovalRef
|
|
179
|
+
if (ref.contextRef !== x.contextRef) e.push('$.relaxationApprovalRef.contextRef: must match action')
|
|
180
|
+
if (ref.sourceRevision !== x.expectedRevision) e.push('$.relaxationApprovalRef.sourceRevision: must equal action revision')
|
|
181
|
+
if (ref.targetActionKind !== x.kind) e.push('$.relaxationApprovalRef.targetActionKind: must match action kind')
|
|
182
|
+
if (ref.targetActionId !== x.actionId) e.push('$.relaxationApprovalRef.targetActionId: must match action id')
|
|
183
|
+
// Expiry and one-time consumption are runtime concerns; validation checks only structure/binding.
|
|
184
|
+
if (!safeOpaque(ref.nonce) || !isBookingDateTime(ref.expiresAt)) e.push('$.relaxationApprovalRef: invalid expiry or nonce')
|
|
185
|
+
}
|
|
186
|
+
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')
|
|
187
|
+
return e
|
|
188
|
+
}
|
|
189
|
+
export function validateBookingSurface(value: unknown): BookingSurfaceValidationResult { const ok = validate(value); const e = ok ? [] : errors(validate); e.push(...cross(value)); return e.length ? { ok: false, errors: e } : { ok: true } }
|
|
190
|
+
function fromCanonical(fn: ValidateFunction, value: unknown): BookingSurfaceValidationResult { const ok = fn(value); const e = ok ? [] : errors(fn); e.push(...cross(value)); return e.length ? { ok: false, errors: e } : { ok: true } }
|
|
191
|
+
export function validateBookingReadAction(value: unknown): BookingSurfaceValidationResult { return fromCanonical(validateActionCanonical, value) }
|
|
192
|
+
export function validateCriterionBlocker(value: unknown): BookingSurfaceValidationResult { return fromCanonical(validateBlockerCanonical, value) }
|
|
193
|
+
export function validateRelaxationApproval(value: unknown): BookingSurfaceValidationResult { return fromCanonical(validateApprovalCanonical, value) }
|
|
194
|
+
export function validateBookingSurfaceEvent(value: unknown): BookingSurfaceValidationResult { return fromCanonical(validateEventCanonical, value) }
|
|
195
|
+
export function validateApprovalAgainstBlocker(approval: unknown, blocker: unknown): BookingSurfaceValidationResult {
|
|
196
|
+
const a = approval as any
|
|
197
|
+
const b = blocker as any
|
|
198
|
+
const blockerResult = validateCriterionBlocker(b)
|
|
199
|
+
if (!blockerResult.ok) return blockerResult
|
|
200
|
+
const approvalResult = validateRelaxationApproval(a)
|
|
201
|
+
if (!approvalResult.ok) return approvalResult
|
|
202
|
+
const fields = ['blockerId', 'sourceActionId', 'sourceReceiptDigest', 'scope', 'code', 'criterionPath', 'valueDigest']
|
|
203
|
+
const mismatches = fields.filter(field => a[field] !== b?.[field])
|
|
204
|
+
return mismatches.length ? { ok: false, errors: mismatches.map(field => `$.${field}: approval does not match blocker`) } : { ok: true }
|
|
446
205
|
}
|
|
447
|
-
|
|
448
|
-
// Keep the public predicates assignable to their declared wire types.
|
|
449
|
-
void (null as unknown as BookingCopilotIngressTurnV1)
|
|
450
|
-
void (null as unknown as BookingCopilotTurnV1)
|
|
451
|
-
void (null as unknown as BookingReadActionV1)
|
|
452
|
-
void (null as unknown as ActionReceiptV1)
|
|
453
|
-
void (null as unknown as BookingSurfaceEventV1)
|