@voyantjs/bookings 0.52.2 → 0.52.3

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/dist/routes.js CHANGED
@@ -1,5 +1,8 @@
1
+ import { ACTION_LEDGER_APPROVAL_ID_HEADER, ActionApprovalDecisionConflictError, ActionLedgerIdempotencyConflictError, actionLedgerService, appendActionLedgerMutation, appendActionLedgerSensitiveRead, buildActionApprovalCommandFingerprint, buildActionLedgerApprovedExecutionFields, decideActionLedgerApproval, evaluateActionLedgerApprovalRequirement, evaluateActionLedgerCapabilityAccess, ledgerSensitiveRead, mapActionLedgerRequestContext, requestActionLedgerApproval, } from "@voyantjs/action-ledger";
1
2
  import { ForbiddenApiError, handleApiError, idempotencyKey, normalizeValidationError, parseJsonBody, parseQuery, requireUserId, UnauthorizedApiError, } from "@voyantjs/hono";
2
3
  import { Hono } from "hono";
4
+ import { z } from "zod";
5
+ import { BOOKING_PII_READ_CAPABILITY, BOOKING_STATUS_CAPABILITIES, } from "./action-ledger-capabilities.js";
3
6
  import { createBookingPiiService } from "./pii.js";
4
7
  import { redactBookingContact, redactTravelerIdentity, shouldRevealBookingPii, } from "./pii-redaction.js";
5
8
  import { BOOKING_ROUTE_RUNTIME_CONTAINER_KEY, buildBookingRouteRuntime, } from "./route-runtime.js";
@@ -17,6 +20,489 @@ function hasPiiScope(scopes, action) {
17
20
  scopes.includes("bookings-pii:*") ||
18
21
  scopes.includes(`bookings-pii:${action}`));
19
22
  }
23
+ const BOOKING_PII_READ_ACTION_NAME = "booking.pii.read";
24
+ const BOOKING_PII_READ_ACTION_VERSION = "v1";
25
+ const BOOKING_PII_DECISION_POLICY = "bookings-pii-scope-or-staff-v1";
26
+ const BOOKING_PII_AUTHORIZATION_SOURCE = "bookings.pii.route";
27
+ const BOOKING_STATUS_APPROVAL_POLICY = "bookings-status-approval-v1";
28
+ const BOOKING_TRAVELER_LEDGER_ACTION_VERSION = "v1";
29
+ const BOOKING_ITEM_LEDGER_ACTION_VERSION = "v1";
30
+ const BOOKING_NOTE_LEDGER_ACTION_VERSION = "v1";
31
+ const TRAVELER_IDENTITY_DISCLOSED_FIELDS = [
32
+ "firstName",
33
+ "lastName",
34
+ "email",
35
+ "phone",
36
+ "specialRequests",
37
+ "notes",
38
+ ];
39
+ const TRAVELER_TRAVEL_DETAIL_DISCLOSED_FIELDS = [
40
+ "nationality",
41
+ "passportNumber",
42
+ "passportExpiry",
43
+ "passportIssuingCountry",
44
+ "passportIssuingAuthority",
45
+ "passportPersonDocumentId",
46
+ "dateOfBirth",
47
+ "dietaryRequirements",
48
+ "accessibilityNeeds",
49
+ "isLeadTraveler",
50
+ "sharingGroupId",
51
+ "roomTypeId",
52
+ "bedPreference",
53
+ "allocations",
54
+ ];
55
+ const TRAVELER_MUTATION_FIELDS = [
56
+ "personId",
57
+ "participantType",
58
+ "travelerCategory",
59
+ "firstName",
60
+ "lastName",
61
+ "email",
62
+ "phone",
63
+ "preferredLanguage",
64
+ "specialRequests",
65
+ "isPrimary",
66
+ "notes",
67
+ ];
68
+ const BOOKING_ITEM_MUTATION_FIELDS = [
69
+ "title",
70
+ "description",
71
+ "itemType",
72
+ "status",
73
+ "serviceDate",
74
+ "startsAt",
75
+ "endsAt",
76
+ "quantity",
77
+ "sellCurrency",
78
+ "unitSellAmountCents",
79
+ "totalSellAmountCents",
80
+ "costCurrency",
81
+ "unitCostAmountCents",
82
+ "totalCostAmountCents",
83
+ "notes",
84
+ "productId",
85
+ "optionId",
86
+ "optionUnitId",
87
+ "pricingCategoryId",
88
+ "sourceSnapshotId",
89
+ "sourceOfferId",
90
+ "metadata",
91
+ ];
92
+ const bookingActionLedgerQuerySchema = z
93
+ .object({
94
+ cursorOccurredAt: z.string().datetime().optional(),
95
+ cursorId: z.string().trim().min(1).optional(),
96
+ limit: z.coerce.number().int().min(1).max(199).optional(),
97
+ })
98
+ .superRefine((value, ctx) => {
99
+ if (Boolean(value.cursorOccurredAt) === Boolean(value.cursorId))
100
+ return;
101
+ ctx.addIssue({
102
+ code: z.ZodIssueCode.custom,
103
+ path: value.cursorOccurredAt ? ["cursorId"] : ["cursorOccurredAt"],
104
+ message: "cursorOccurredAt and cursorId must be provided together",
105
+ });
106
+ })
107
+ .transform(({ cursorOccurredAt, cursorId, ...query }) => ({
108
+ ...query,
109
+ cursor: cursorOccurredAt && cursorId
110
+ ? {
111
+ occurredAt: cursorOccurredAt,
112
+ id: cursorId,
113
+ }
114
+ : undefined,
115
+ }));
116
+ const decideBookingActionApprovalBodySchema = z.object({
117
+ status: z.enum(["approved", "denied"]),
118
+ });
119
+ function getActionLedgerRequestContext(c) {
120
+ return {
121
+ userId: c.get("userId") ?? null,
122
+ agentId: c.get("agentId") ?? null,
123
+ workflowPrincipalId: c.get("workflowPrincipalId") ?? null,
124
+ principalSubtype: c.get("principalSubtype") ?? null,
125
+ sessionId: c.get("sessionId") ?? null,
126
+ apiTokenId: c.get("apiTokenId") ?? c.get("apiKeyId") ?? null,
127
+ callerType: c.get("callerType") ?? null,
128
+ actor: c.get("actor") ?? null,
129
+ isInternalRequest: c.get("isInternalRequest") ?? false,
130
+ organizationId: c.get("organizationId") ?? null,
131
+ workflowRunId: c.get("workflowRunId") ?? null,
132
+ workflowStepId: c.get("workflowStepId") ?? null,
133
+ correlationId: c.req.header("x-correlation-id") ?? c.req.header("x-request-id") ?? null,
134
+ };
135
+ }
136
+ function changedBookingMutationFields(input, before, after) {
137
+ const fields = Object.keys(input).filter((field) => !ignoredBookingMutationFields.has(field));
138
+ if (!before || !after)
139
+ return fields.sort();
140
+ return fields.filter((field) => !bookingValuesEqual(before[field], after[field])).sort();
141
+ }
142
+ function changedBookingTravelerFields(input, before, after) {
143
+ const travelerFields = new Set(TRAVELER_MUTATION_FIELDS);
144
+ return changedBookingMutationFields(input, before, after).filter((field) => travelerFields.has(field));
145
+ }
146
+ function changedBookingTravelDetailFields(input) {
147
+ const travelDetailFields = new Set(TRAVELER_TRAVEL_DETAIL_DISCLOSED_FIELDS);
148
+ return Object.keys(input)
149
+ .filter((field) => travelDetailFields.has(field))
150
+ .sort();
151
+ }
152
+ function changedBookingItemFields(input, before, after) {
153
+ const itemFields = new Set(BOOKING_ITEM_MUTATION_FIELDS);
154
+ return changedBookingMutationFields(input, before, after).filter((field) => itemFields.has(field));
155
+ }
156
+ function bookingMutationSummary(action, fields, subject) {
157
+ if (action === "delete")
158
+ return `Deleted ${subject}`;
159
+ if (fields.length === 0)
160
+ return action === "create" ? `Created ${subject}` : `Updated ${subject}`;
161
+ const verb = action === "create" ? "Created" : "Updated";
162
+ return `${verb} ${subject} fields: ${fields.join(", ")}`;
163
+ }
164
+ async function appendBookingMutationLedgerEntry(c, input) {
165
+ return appendBookingMutationLedgerEntryToDb(c.get("db"), getActionLedgerRequestContext(c), input);
166
+ }
167
+ async function appendBookingMutationLedgerEntryToDb(db, context, input) {
168
+ return appendActionLedgerMutation(db, {
169
+ context,
170
+ actionName: input.actionName,
171
+ actionVersion: input.actionVersion,
172
+ actionKind: input.action,
173
+ evaluatedRisk: input.evaluatedRisk ?? "medium",
174
+ targetType: input.targetType,
175
+ targetId: input.targetId,
176
+ routeOrToolName: input.routeOrToolName,
177
+ authorizationSource: input.authorizationSource ?? "bookings.route",
178
+ mutationDetail: {
179
+ summary: input.summary ?? bookingMutationSummary(input.action, input.changedFields, input.subject),
180
+ reversalKind: "none",
181
+ },
182
+ });
183
+ }
184
+ async function appendBookingTravelerMutationLedgerEntryToDb(db, context, input) {
185
+ return appendBookingMutationLedgerEntryToDb(db, context, {
186
+ ...input,
187
+ actionName: input.actionName,
188
+ actionVersion: BOOKING_TRAVELER_LEDGER_ACTION_VERSION,
189
+ targetType: "booking_traveler",
190
+ targetId: input.travelerId,
191
+ authorizationSource: BOOKING_PII_AUTHORIZATION_SOURCE,
192
+ evaluatedRisk: input.evaluatedRisk ?? "high",
193
+ });
194
+ }
195
+ function bookingValuesEqual(left, right) {
196
+ if (left instanceof Date || right instanceof Date) {
197
+ const leftTime = left instanceof Date ? left.getTime() : new Date(String(left)).getTime();
198
+ const rightTime = right instanceof Date ? right.getTime() : new Date(String(right)).getTime();
199
+ return leftTime === rightTime;
200
+ }
201
+ return JSON.stringify(left) === JSON.stringify(right);
202
+ }
203
+ const ignoredBookingMutationFields = new Set(["updatedAt", "createdAt"]);
204
+ async function authorizeBookingStatusMutation(c, input) {
205
+ const capability = BOOKING_STATUS_CAPABILITIES[input.key];
206
+ const access = evaluateActionLedgerCapabilityAccess({
207
+ definition: capability,
208
+ actor: c.get("actor"),
209
+ callerType: c.get("callerType"),
210
+ scopes: c.get("scopes"),
211
+ isInternalRequest: c.get("isInternalRequest"),
212
+ });
213
+ if (access.allowed) {
214
+ const approvalRequirement = evaluateActionLedgerApprovalRequirement({
215
+ access,
216
+ conditionalApprovalRequired: requiresBookingStatusApproval(c, input.key),
217
+ reasonCode: bookingStatusApprovalReason(c, input.key),
218
+ });
219
+ if (approvalRequirement.required) {
220
+ const approvedAction = await resolveApprovedBookingStatusAction(c, input, access, approvalRequirement);
221
+ if (approvedAction) {
222
+ if (!approvedAction.allowed)
223
+ return approvedAction;
224
+ return { allowed: true, access, approvedAction: approvedAction.action };
225
+ }
226
+ const idempotencyKey = c.req.header("idempotency-key") ?? null;
227
+ if (!idempotencyKey) {
228
+ return {
229
+ allowed: false,
230
+ response: c.json({
231
+ error: "Approval-required booking status actions require an Idempotency-Key",
232
+ }, 400),
233
+ };
234
+ }
235
+ const idempotencyScope = `${input.routeOrToolName}:${input.bookingId}`;
236
+ const idempotencyFingerprint = await buildBookingStatusApprovalFingerprint(input, await loadBookingStatusApprovalTargetState(c, input.bookingId), access, approvalRequirement);
237
+ const requestInput = {
238
+ context: getActionLedgerRequestContext(c),
239
+ actionName: input.actionName,
240
+ actionVersion: capability.version,
241
+ actionKind: "update",
242
+ evaluatedRisk: approvalRequirement.evaluatedRisk,
243
+ targetType: "booking",
244
+ targetId: input.bookingId,
245
+ routeOrToolName: input.routeOrToolName,
246
+ capabilityId: access.capabilityId,
247
+ capabilityVersion: access.capabilityVersion,
248
+ authorizationSource: access.authorizationSource,
249
+ idempotencyScope,
250
+ idempotencyKey,
251
+ idempotencyFingerprint,
252
+ mutationDetail: {
253
+ summary: `Booking status ${capability.action} awaiting approval: ${approvalRequirement.reasonCode}`,
254
+ reversalKind: "none",
255
+ },
256
+ approval: {
257
+ policyName: BOOKING_STATUS_APPROVAL_POLICY,
258
+ policyVersion: capability.version,
259
+ riskSnapshot: approvalRequirement.evaluatedRisk,
260
+ reasonCode: approvalRequirement.reasonCode,
261
+ },
262
+ };
263
+ let result;
264
+ try {
265
+ result = await requestActionLedgerApproval(c.get("db"), requestInput);
266
+ }
267
+ catch (error) {
268
+ if (error instanceof ActionLedgerIdempotencyConflictError) {
269
+ return {
270
+ allowed: false,
271
+ response: c.json({
272
+ error: error.message,
273
+ existingActionId: error.existingActionId,
274
+ }, 409),
275
+ };
276
+ }
277
+ throw error;
278
+ }
279
+ return {
280
+ allowed: false,
281
+ response: c.json({
282
+ data: {
283
+ approvalRequired: true,
284
+ requestedAction: {
285
+ id: result.requestedAction.id,
286
+ status: result.requestedAction.status,
287
+ actionName: result.requestedAction.actionName,
288
+ targetType: result.requestedAction.targetType,
289
+ targetId: result.requestedAction.targetId,
290
+ },
291
+ approval: {
292
+ id: result.approval.id,
293
+ status: result.approval.status,
294
+ requestedActionId: result.approval.requestedActionId,
295
+ requestedByPrincipalId: result.approval.requestedByPrincipalId,
296
+ assignedToPrincipalId: result.approval.assignedToPrincipalId,
297
+ policyName: result.approval.policyName,
298
+ policyVersion: result.approval.policyVersion,
299
+ riskSnapshot: result.approval.riskSnapshot,
300
+ reasonCode: result.approval.reasonCode,
301
+ expiresAt: result.approval.expiresAt,
302
+ createdAt: result.approval.createdAt,
303
+ },
304
+ replayed: result.replayed,
305
+ },
306
+ }, 202),
307
+ };
308
+ }
309
+ return { allowed: true, access };
310
+ }
311
+ await appendActionLedgerMutation(c.get("db"), {
312
+ context: getActionLedgerRequestContext(c),
313
+ actionName: input.actionName,
314
+ actionVersion: capability.version,
315
+ actionKind: "update",
316
+ status: "denied",
317
+ evaluatedRisk: access.evaluatedRisk,
318
+ targetType: "booking",
319
+ targetId: input.bookingId,
320
+ routeOrToolName: input.routeOrToolName,
321
+ capabilityId: access.capabilityId,
322
+ capabilityVersion: access.capabilityVersion,
323
+ authorizationSource: access.authorizationSource,
324
+ mutationDetail: {
325
+ summary: `Booking status ${capability.action} denied: ${access.reason}`,
326
+ reversalKind: "none",
327
+ },
328
+ });
329
+ return {
330
+ allowed: false,
331
+ response: handleApiError(new ForbiddenApiError(), c),
332
+ };
333
+ }
334
+ async function resolveApprovedBookingStatusAction(c, input, access, approvalRequirement) {
335
+ const approvalId = c.req.header(ACTION_LEDGER_APPROVAL_ID_HEADER);
336
+ if (!approvalId)
337
+ return null;
338
+ const executionFingerprint = await buildBookingStatusApprovalFingerprint(input, await loadBookingStatusApprovalTargetState(c, input.bookingId), access, approvalRequirement);
339
+ const actorFields = mapActionLedgerRequestContext(getActionLedgerRequestContext(c));
340
+ const validation = await actionLedgerService.validateApprovedAction(c.get("db"), {
341
+ approvalId,
342
+ actionName: input.actionName,
343
+ actionVersion: BOOKING_STATUS_CAPABILITIES[input.key].version,
344
+ requestedActionKind: "update",
345
+ requestedActionStatus: "awaiting_approval",
346
+ targetType: "booking",
347
+ targetId: input.bookingId,
348
+ routeOrToolName: input.routeOrToolName,
349
+ principalType: actorFields.principalType,
350
+ principalId: actorFields.principalId,
351
+ idempotencyFingerprint: executionFingerprint,
352
+ executionActionKind: "update",
353
+ executionStatus: "succeeded",
354
+ });
355
+ if (!validation.ok) {
356
+ return {
357
+ allowed: false,
358
+ response: actionApprovalValidationResponse(c, validation),
359
+ };
360
+ }
361
+ return {
362
+ allowed: true,
363
+ action: {
364
+ requestedActionId: validation.requestedAction.id,
365
+ approvalId: validation.approval.id,
366
+ idempotencyFingerprint: validation.idempotencyFingerprint,
367
+ },
368
+ };
369
+ }
370
+ function buildBookingStatusApprovalFingerprint(input, targetState, access, approvalRequirement) {
371
+ return buildActionApprovalCommandFingerprint({
372
+ actionName: input.actionName,
373
+ actionVersion: BOOKING_STATUS_CAPABILITIES[input.key].version,
374
+ targetType: "booking",
375
+ targetId: input.bookingId,
376
+ commandInput: {
377
+ command: input.commandInput ?? null,
378
+ targetState,
379
+ },
380
+ approvalPolicy: approvalRequirement.approvalPolicy,
381
+ capabilityId: access.capabilityId,
382
+ capabilityVersion: access.capabilityVersion,
383
+ evaluatedRisk: approvalRequirement.evaluatedRisk,
384
+ reasonCode: approvalRequirement.reasonCode,
385
+ });
386
+ }
387
+ async function loadBookingStatusApprovalTargetState(c, bookingId) {
388
+ const booking = await bookingsService.getBookingById(c.get("db"), bookingId);
389
+ if (!booking)
390
+ return { exists: false };
391
+ return {
392
+ exists: true,
393
+ status: booking.status,
394
+ sellCurrency: booking.sellCurrency,
395
+ sellAmountCents: booking.sellAmountCents,
396
+ costAmountCents: booking.costAmountCents,
397
+ customerPaymentPolicy: booking.customerPaymentPolicy,
398
+ holdExpiresAt: serializeBookingApprovalDate(booking.holdExpiresAt),
399
+ confirmedAt: serializeBookingApprovalDate(booking.confirmedAt),
400
+ awaitingPaymentAt: serializeBookingApprovalDate(booking.awaitingPaymentAt),
401
+ paidAt: serializeBookingApprovalDate(booking.paidAt),
402
+ cancelledAt: serializeBookingApprovalDate(booking.cancelledAt),
403
+ completedAt: serializeBookingApprovalDate(booking.completedAt),
404
+ expiredAt: serializeBookingApprovalDate(booking.expiredAt),
405
+ };
406
+ }
407
+ function serializeBookingApprovalDate(value) {
408
+ if (!value)
409
+ return null;
410
+ return value instanceof Date ? value.toISOString() : value;
411
+ }
412
+ function actionApprovalValidationResponse(c, validation) {
413
+ switch (validation.reason) {
414
+ case "not_found":
415
+ return c.json({ error: "Action approval not found" }, 404);
416
+ case "not_approved":
417
+ return c.json({
418
+ error: "Action approval is not approved",
419
+ approvalId: validation.approval?.id,
420
+ status: validation.status,
421
+ }, 409);
422
+ case "expired":
423
+ return c.json({
424
+ error: "Action approval has expired",
425
+ approvalId: validation.approval?.id,
426
+ }, 409);
427
+ case "mismatched_action":
428
+ return c.json({
429
+ error: "Action approval does not match this booking status action",
430
+ approvalId: validation.approval?.id,
431
+ }, 409);
432
+ case "already_executed":
433
+ return c.json({
434
+ error: "Action approval has already been executed",
435
+ approvalId: validation.approval?.id,
436
+ existingActionId: validation.existingActionId,
437
+ }, 409);
438
+ case "missing_fingerprint":
439
+ return c.json({
440
+ error: "Action approval is missing an approved command fingerprint",
441
+ approvalId: validation.approval?.id,
442
+ }, 409);
443
+ case "fingerprint_mismatch":
444
+ return c.json({
445
+ error: "Action approval command input does not match the approved request",
446
+ approvalId: validation.approval?.id,
447
+ }, 409);
448
+ case "principal_mismatch":
449
+ return c.json({ error: "Action approval belongs to a different principal" }, 403);
450
+ }
451
+ const exhaustiveReason = validation.reason;
452
+ return c.json({ error: `Unhandled action approval validation failure: ${exhaustiveReason}` }, 500);
453
+ }
454
+ function bookingStatusMutationRuntime(c, auth) {
455
+ const approvedExecution = auth.approvedAction
456
+ ? buildActionLedgerApprovedExecutionFields(auth.approvedAction)
457
+ : null;
458
+ return {
459
+ eventBus: c.get("eventBus"),
460
+ actionLedgerContext: getActionLedgerRequestContext(c),
461
+ actionLedgerAuthorizationSource: auth.access.authorizationSource,
462
+ actionLedgerCausationActionId: approvedExecution?.causationActionId ?? null,
463
+ actionLedgerApprovalId: approvedExecution?.approvalId ?? null,
464
+ actionLedgerIdempotencyScope: approvedExecution?.idempotencyScope ?? null,
465
+ actionLedgerIdempotencyKey: approvedExecution?.idempotencyKey ?? null,
466
+ actionLedgerIdempotencyFingerprint: approvedExecution?.idempotencyFingerprint ?? null,
467
+ };
468
+ }
469
+ function requiresBookingStatusApproval(c, key) {
470
+ const capability = BOOKING_STATUS_CAPABILITIES[key];
471
+ if (capability.approvalPolicy !== "conditional")
472
+ return false;
473
+ return (c.get("callerType") === "agent" ||
474
+ c.get("callerType") === "workflow" ||
475
+ Boolean(c.get("agentId")) ||
476
+ Boolean(c.get("workflowPrincipalId")));
477
+ }
478
+ function bookingStatusApprovalReason(c, key) {
479
+ if (c.get("callerType") === "agent" || c.get("agentId")) {
480
+ return `${key}_requested_by_agent`;
481
+ }
482
+ if (c.get("callerType") === "workflow" || c.get("workflowPrincipalId")) {
483
+ return `${key}_requested_by_workflow`;
484
+ }
485
+ return null;
486
+ }
487
+ async function logBookingPiiReadActionLedger(c, input) {
488
+ await appendActionLedgerSensitiveRead(c.get("db"), {
489
+ context: getActionLedgerRequestContext(c),
490
+ actionName: BOOKING_PII_READ_ACTION_NAME,
491
+ actionVersion: BOOKING_PII_READ_ACTION_VERSION,
492
+ status: input.status,
493
+ evaluatedRisk: input.evaluatedRisk ?? "high",
494
+ targetType: "booking_traveler",
495
+ targetId: input.travelerId,
496
+ routeOrToolName: input.routeOrToolName,
497
+ capabilityId: BOOKING_PII_READ_CAPABILITY.id,
498
+ capabilityVersion: BOOKING_PII_READ_CAPABILITY.version,
499
+ authorizationSource: input.authorizationSource ?? BOOKING_PII_AUTHORIZATION_SOURCE,
500
+ reasonCode: input.reason,
501
+ disclosedFieldSet: input.status === "succeeded" ? (input.disclosedFieldSet ?? []) : [],
502
+ disclosureSummary: input.disclosureSummary ?? null,
503
+ decisionPolicy: input.decisionPolicy ?? BOOKING_PII_DECISION_POLICY,
504
+ });
505
+ }
20
506
  async function logBookingPiiAccess(c, input) {
21
507
  await c
22
508
  .get("db")
@@ -35,7 +521,7 @@ async function logBookingPiiAccess(c, input) {
35
521
  }
36
522
  async function authorizeBookingPiiAccess(c, input) {
37
523
  if (c.get("isInternalRequest")) {
38
- return { allowed: true };
524
+ return { allowed: true, access: undefined };
39
525
  }
40
526
  const userId = c.get("userId");
41
527
  if (!userId) {
@@ -44,6 +530,15 @@ async function authorizeBookingPiiAccess(c, input) {
44
530
  outcome: "denied",
45
531
  reason: "missing_user",
46
532
  });
533
+ if (input.action === "read") {
534
+ await logBookingPiiReadActionLedger(c, {
535
+ travelerId: input.travelerId,
536
+ status: "denied",
537
+ reason: "missing_user",
538
+ routeOrToolName: "bookings.pii.authorize",
539
+ disclosureSummary: "Booking PII read denied before reveal",
540
+ });
541
+ }
47
542
  return {
48
543
  allowed: false,
49
544
  response: handleApiError(new UnauthorizedApiError(), c),
@@ -66,15 +561,61 @@ async function authorizeBookingPiiAccess(c, input) {
66
561
  outcome: "denied",
67
562
  reason: "custom_policy_denied",
68
563
  });
564
+ if (input.action === "read") {
565
+ await logBookingPiiReadActionLedger(c, {
566
+ travelerId: input.travelerId,
567
+ status: "denied",
568
+ reason: "custom_policy_denied",
569
+ routeOrToolName: "bookings.pii.authorize",
570
+ disclosureSummary: "Booking PII read denied before reveal",
571
+ decisionPolicy: "custom",
572
+ });
573
+ }
69
574
  return {
70
575
  allowed: false,
71
576
  response: handleApiError(new ForbiddenApiError(), c),
72
577
  };
73
578
  }
74
- return { allowed: true };
579
+ return { allowed: true, access: undefined };
75
580
  }
76
581
  const actor = c.get("actor");
77
582
  const scopes = c.get("scopes");
583
+ if (input.action === "read") {
584
+ const access = evaluateActionLedgerCapabilityAccess({
585
+ definition: BOOKING_PII_READ_CAPABILITY,
586
+ actor,
587
+ callerType: c.get("callerType"),
588
+ scopes,
589
+ isInternalRequest: c.get("isInternalRequest"),
590
+ });
591
+ if (!access.allowed) {
592
+ await logBookingPiiAccess(c, {
593
+ ...input,
594
+ outcome: "denied",
595
+ reason: access.reason,
596
+ metadata: {
597
+ actor: actor ?? null,
598
+ authorizationSource: access.authorizationSource,
599
+ capabilityId: access.capabilityId,
600
+ capabilityVersion: access.capabilityVersion,
601
+ },
602
+ });
603
+ await logBookingPiiReadActionLedger(c, {
604
+ travelerId: input.travelerId,
605
+ status: "denied",
606
+ reason: access.reason,
607
+ routeOrToolName: "bookings.pii.authorize",
608
+ disclosureSummary: "Booking PII read denied before reveal",
609
+ authorizationSource: access.authorizationSource,
610
+ evaluatedRisk: access.evaluatedRisk,
611
+ });
612
+ return {
613
+ allowed: false,
614
+ response: handleApiError(new ForbiddenApiError(), c),
615
+ };
616
+ }
617
+ return { allowed: true, access };
618
+ }
78
619
  const allowed = hasPiiScope(scopes, input.action) || actor === "staff";
79
620
  if (!allowed) {
80
621
  await logBookingPiiAccess(c, {
@@ -88,7 +629,7 @@ async function authorizeBookingPiiAccess(c, input) {
88
629
  response: handleApiError(new ForbiddenApiError(), c),
89
630
  };
90
631
  }
91
- return { allowed: true };
632
+ return { allowed: true, access: undefined };
92
633
  }
93
634
  function handleKmsConfigError(c, error) {
94
635
  if (error instanceof Error) {
@@ -127,6 +668,195 @@ async function createAuditedBookingPiiService(c, bookingId) {
127
668
  },
128
669
  });
129
670
  }
671
+ function serializeBookingActionLedgerDate(value) {
672
+ const date = value instanceof Date ? value : new Date(value);
673
+ if (Number.isNaN(date.getTime())) {
674
+ throw new Error("Booking action ledger timestamp must be a valid date");
675
+ }
676
+ return date.toISOString();
677
+ }
678
+ function serializeBookingActionLedgerEntry(entry) {
679
+ return {
680
+ ...entry,
681
+ occurredAt: serializeBookingActionLedgerDate(entry.occurredAt),
682
+ createdAt: serializeBookingActionLedgerDate(entry.createdAt),
683
+ };
684
+ }
685
+ function toBookingActionLedgerCursor(entry) {
686
+ return {
687
+ occurredAt: serializeBookingActionLedgerDate(entry.occurredAt),
688
+ id: entry.id,
689
+ };
690
+ }
691
+ function sortBookingActionLedgerEntries(entries) {
692
+ return [...entries].sort((a, b) => {
693
+ const occurredAtDelta = new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime();
694
+ if (occurredAtDelta !== 0)
695
+ return occurredAtDelta;
696
+ return b.id.localeCompare(a.id);
697
+ });
698
+ }
699
+ function buildBookingActionLedgerPage({ bookingEntries, travelerEntries, itemEntries = [], limit, }) {
700
+ const entriesById = new Map();
701
+ for (const entry of bookingEntries) {
702
+ entriesById.set(entry.id, entry);
703
+ }
704
+ for (const entry of travelerEntries) {
705
+ entriesById.set(entry.id, entry);
706
+ }
707
+ for (const entry of itemEntries) {
708
+ entriesById.set(entry.id, entry);
709
+ }
710
+ const sortedEntries = sortBookingActionLedgerEntries([...entriesById.values()]);
711
+ const entries = sortedEntries.slice(0, limit);
712
+ const lastEntry = entries.at(-1);
713
+ const nextCursor = sortedEntries.length > limit && lastEntry ? toBookingActionLedgerCursor(lastEntry) : null;
714
+ return { entries, nextCursor };
715
+ }
716
+ async function listBookingActionLedger(c) {
717
+ const bookingId = c.req.param("id");
718
+ if (!bookingId) {
719
+ return c.json({ error: "Booking not found" }, 404);
720
+ }
721
+ const query = parseQuery(c, bookingActionLedgerQuerySchema);
722
+ const limit = query.limit ?? 50;
723
+ const queryLimit = limit + 1;
724
+ const booking = await bookingsService.getBookingById(c.get("db"), bookingId);
725
+ if (!booking) {
726
+ return c.json({ error: "Booking not found" }, 404);
727
+ }
728
+ const travelers = await bookingsService.listTravelers(c.get("db"), bookingId);
729
+ const reveal = shouldRevealBookingPii({
730
+ actor: c.get("actor"),
731
+ scopes: c.get("scopes"),
732
+ callerType: c.get("callerType"),
733
+ isInternalRequest: c.get("isInternalRequest"),
734
+ });
735
+ const visibleTravelers = reveal ? travelers : travelers.map((row) => redactTravelerIdentity(row));
736
+ const travelerIds = travelers.map((traveler) => traveler.id);
737
+ const items = await bookingsService.listItems(c.get("db"), bookingId);
738
+ const itemIds = items.map((item) => item.id);
739
+ const [bookingEntriesResult, travelerEntriesResult, itemEntriesResult] = await Promise.all([
740
+ actionLedgerService.listEntries(c.get("db"), {
741
+ targetType: "booking",
742
+ targetId: bookingId,
743
+ cursor: query.cursor,
744
+ limit: queryLimit,
745
+ }),
746
+ travelerIds.length > 0
747
+ ? actionLedgerService.listEntries(c.get("db"), {
748
+ targetType: "booking_traveler",
749
+ targetIds: travelerIds,
750
+ cursor: query.cursor,
751
+ limit: queryLimit,
752
+ })
753
+ : Promise.resolve({ entries: [], nextCursor: null }),
754
+ itemIds.length > 0
755
+ ? actionLedgerService.listEntries(c.get("db"), {
756
+ targetType: "booking_item",
757
+ targetIds: itemIds,
758
+ cursor: query.cursor,
759
+ limit: queryLimit,
760
+ })
761
+ : Promise.resolve({ entries: [], nextCursor: null }),
762
+ ]);
763
+ const page = buildBookingActionLedgerPage({
764
+ bookingEntries: bookingEntriesResult.entries,
765
+ travelerEntries: travelerEntriesResult.entries,
766
+ itemEntries: itemEntriesResult.entries,
767
+ limit,
768
+ });
769
+ return c.json({
770
+ data: page.entries.map(serializeBookingActionLedgerEntry),
771
+ travelers: visibleTravelers.map((traveler) => ({
772
+ id: traveler.id,
773
+ firstName: traveler.firstName,
774
+ lastName: traveler.lastName,
775
+ })),
776
+ pageInfo: {
777
+ nextCursor: page.nextCursor,
778
+ },
779
+ });
780
+ }
781
+ function serializeBookingActionApproval(approval) {
782
+ return {
783
+ ...approval,
784
+ expiresAt: approval.expiresAt ? serializeBookingActionLedgerDate(approval.expiresAt) : null,
785
+ decidedAt: approval.decidedAt ? serializeBookingActionLedgerDate(approval.decidedAt) : null,
786
+ createdAt: serializeBookingActionLedgerDate(approval.createdAt),
787
+ };
788
+ }
789
+ function findBookingStatusCapability(capabilityId) {
790
+ return Object.values(BOOKING_STATUS_CAPABILITIES).find((capability) => capability.id === capabilityId);
791
+ }
792
+ async function decideBookingActionApproval(c) {
793
+ const bookingId = c.req.param("id");
794
+ const approvalId = c.req.param("approvalId");
795
+ if (!bookingId || !approvalId) {
796
+ return c.json({ error: "Action approval not found" }, 404);
797
+ }
798
+ if (!c.get("isInternalRequest") && !c.get("userId")) {
799
+ return handleApiError(new UnauthorizedApiError(), c);
800
+ }
801
+ const body = await parseJsonBody(c, decideBookingActionApprovalBodySchema);
802
+ const existing = await actionLedgerService.getApproval(c.get("db"), approvalId);
803
+ if (!existing?.requestedAction?.entry) {
804
+ return c.json({ error: "Action approval not found" }, 404);
805
+ }
806
+ const requestedAction = existing.requestedAction.entry;
807
+ if (requestedAction.targetType !== "booking" || requestedAction.targetId !== bookingId) {
808
+ return c.json({ error: "Action approval not found" }, 404);
809
+ }
810
+ const capability = findBookingStatusCapability(requestedAction.capabilityId);
811
+ if (!capability) {
812
+ return c.json({ error: "Action approval is not a booking status approval" }, 409);
813
+ }
814
+ const access = evaluateActionLedgerCapabilityAccess({
815
+ definition: capability,
816
+ actor: c.get("actor"),
817
+ callerType: c.get("callerType"),
818
+ scopes: c.get("scopes"),
819
+ isInternalRequest: c.get("isInternalRequest"),
820
+ });
821
+ if (!access.allowed) {
822
+ return handleApiError(new ForbiddenApiError(), c);
823
+ }
824
+ try {
825
+ const result = await decideActionLedgerApproval(c.get("db"), {
826
+ context: getActionLedgerRequestContext(c),
827
+ id: approvalId,
828
+ status: body.status,
829
+ actionName: "booking.status.approval.decide",
830
+ actionVersion: capability.version,
831
+ evaluatedRisk: requestedAction.evaluatedRisk,
832
+ targetType: "booking",
833
+ targetId: bookingId,
834
+ routeOrToolName: "bookings.approvals.decide",
835
+ capabilityId: capability.id,
836
+ capabilityVersion: capability.version,
837
+ authorizationSource: access.authorizationSource,
838
+ });
839
+ if (!result) {
840
+ return c.json({ error: "Action approval not found" }, 404);
841
+ }
842
+ return c.json({
843
+ data: {
844
+ approval: serializeBookingActionApproval(result.approval),
845
+ decisionAction: serializeBookingActionLedgerEntry(result.decisionAction),
846
+ },
847
+ });
848
+ }
849
+ catch (error) {
850
+ if (error instanceof ActionApprovalDecisionConflictError) {
851
+ return c.json({
852
+ error: "Action approval has already been decided",
853
+ approvalId: error.approvalId,
854
+ status: error.currentStatus,
855
+ }, 409);
856
+ }
857
+ throw error;
858
+ }
859
+ }
130
860
  // ==========================================================================
131
861
  // Bookings — method-chained for Hono RPC type inference
132
862
  // ==========================================================================
@@ -226,6 +956,10 @@ export const bookingRoutes = new Hono()
226
956
  });
227
957
  return c.json({ data: reveal ? row : redactBookingContact(row) });
228
958
  })
959
+ // 2b. GET /:id/action-ledger — Booking-scoped action timeline
960
+ .get("/:id/action-ledger", listBookingActionLedger)
961
+ // 2c. POST /:id/action-approvals/:approvalId/decide — Booking-scoped approval decision
962
+ .post("/:id/action-approvals/:approvalId/decide", decideBookingActionApproval)
229
963
  // 3. POST /reserve — Reserve inventory and create on-hold booking
230
964
  .post("/reserve", idempotencyKey({ scope: "POST /v1/admin/bookings/reserve" }), async (c) => {
231
965
  const result = await bookingsService.reserveBooking(c.get("db"), await parseJsonBody(c, reserveBookingSchema), c.get("userId"));
@@ -341,7 +1075,17 @@ export const bookingRoutes = new Hono()
341
1075
  // ==========================================================================
342
1076
  // 8. POST /:id/confirm — Confirm an on-hold booking
343
1077
  .post("/:id/confirm", async (c) => {
344
- const result = await bookingsService.confirmBooking(c.get("db"), c.req.param("id"), await parseJsonBody(c, confirmBookingSchema), c.get("userId"), { eventBus: c.get("eventBus") });
1078
+ const bookingId = c.req.param("id");
1079
+ const data = await parseJsonBody(c, confirmBookingSchema);
1080
+ const auth = await authorizeBookingStatusMutation(c, {
1081
+ key: "confirm",
1082
+ actionName: "booking.status.confirm",
1083
+ routeOrToolName: "bookings.confirm",
1084
+ bookingId,
1085
+ });
1086
+ if (!auth.allowed)
1087
+ return auth.response;
1088
+ const result = await bookingsService.confirmBooking(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
345
1089
  if (result.status === "not_found") {
346
1090
  return c.json({ error: "Booking not found" }, 404);
347
1091
  }
@@ -375,7 +1119,20 @@ export const bookingRoutes = new Hono()
375
1119
  })
376
1120
  // 10. POST /:id/expire — Expire an on-hold booking
377
1121
  .post("/:id/expire", async (c) => {
378
- const result = await bookingsService.expireBooking(c.get("db"), c.req.param("id"), await parseJsonBody(c, expireBookingSchema), c.get("userId"), { eventBus: c.get("eventBus"), cause: "route" });
1122
+ const bookingId = c.req.param("id");
1123
+ const data = await parseJsonBody(c, expireBookingSchema);
1124
+ const auth = await authorizeBookingStatusMutation(c, {
1125
+ key: "expire",
1126
+ actionName: "booking.status.expire",
1127
+ routeOrToolName: "bookings.expire",
1128
+ bookingId,
1129
+ });
1130
+ if (!auth.allowed)
1131
+ return auth.response;
1132
+ const result = await bookingsService.expireBooking(c.get("db"), bookingId, data, c.get("userId"), {
1133
+ ...bookingStatusMutationRuntime(c, auth),
1134
+ cause: "route",
1135
+ });
379
1136
  if (result.status === "not_found") {
380
1137
  return c.json({ error: "Booking not found" }, 404);
381
1138
  }
@@ -393,7 +1150,18 @@ export const bookingRoutes = new Hono()
393
1150
  })
394
1151
  // 11. POST /:id/cancel — Cancel a booking and release allocations
395
1152
  .post("/:id/cancel", async (c) => {
396
- const result = await bookingsService.cancelBooking(c.get("db"), c.req.param("id"), await parseJsonBody(c, cancelBookingSchema), c.get("userId"), { eventBus: c.get("eventBus") });
1153
+ const bookingId = c.req.param("id");
1154
+ const data = await parseJsonBody(c, cancelBookingSchema);
1155
+ const auth = await authorizeBookingStatusMutation(c, {
1156
+ key: "cancel",
1157
+ actionName: "booking.status.cancel",
1158
+ routeOrToolName: "bookings.cancel",
1159
+ bookingId,
1160
+ commandInput: data,
1161
+ });
1162
+ if (!auth.allowed)
1163
+ return auth.response;
1164
+ const result = await bookingsService.cancelBooking(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
397
1165
  if (result.status === "not_found") {
398
1166
  return c.json({ error: "Booking not found" }, 404);
399
1167
  }
@@ -407,7 +1175,17 @@ export const bookingRoutes = new Hono()
407
1175
  })
408
1176
  // 11a. POST /:id/start — Mark a confirmed booking as in-progress
409
1177
  .post("/:id/start", async (c) => {
410
- const result = await bookingsService.startBooking(c.get("db"), c.req.param("id"), await parseJsonBody(c, startBookingSchema), c.get("userId"), { eventBus: c.get("eventBus") });
1178
+ const bookingId = c.req.param("id");
1179
+ const data = await parseJsonBody(c, startBookingSchema);
1180
+ const auth = await authorizeBookingStatusMutation(c, {
1181
+ key: "start",
1182
+ actionName: "booking.status.start",
1183
+ routeOrToolName: "bookings.start",
1184
+ bookingId,
1185
+ });
1186
+ if (!auth.allowed)
1187
+ return auth.response;
1188
+ const result = await bookingsService.startBooking(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
411
1189
  if (result.status === "not_found") {
412
1190
  return c.json({ error: "Booking not found" }, 404);
413
1191
  }
@@ -421,7 +1199,17 @@ export const bookingRoutes = new Hono()
421
1199
  })
422
1200
  // 11b. POST /:id/complete — Mark an in-progress booking as completed
423
1201
  .post("/:id/complete", async (c) => {
424
- const result = await bookingsService.completeBooking(c.get("db"), c.req.param("id"), await parseJsonBody(c, completeBookingSchema), c.get("userId"), { eventBus: c.get("eventBus") });
1202
+ const bookingId = c.req.param("id");
1203
+ const data = await parseJsonBody(c, completeBookingSchema);
1204
+ const auth = await authorizeBookingStatusMutation(c, {
1205
+ key: "complete",
1206
+ actionName: "booking.status.complete",
1207
+ routeOrToolName: "bookings.complete",
1208
+ bookingId,
1209
+ });
1210
+ if (!auth.allowed)
1211
+ return auth.response;
1212
+ const result = await bookingsService.completeBooking(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
425
1213
  if (result.status === "not_found") {
426
1214
  return c.json({ error: "Booking not found" }, 404);
427
1215
  }
@@ -438,7 +1226,18 @@ export const bookingRoutes = new Hono()
438
1226
  // allocations, or fulfillments. Always emits booking.status_overridden for
439
1227
  // audit. Requires a non-empty `reason`.
440
1228
  .post("/:id/override-status", async (c) => {
441
- const result = await bookingsService.overrideBookingStatus(c.get("db"), c.req.param("id"), await parseJsonBody(c, overrideBookingStatusSchema), c.get("userId"), { eventBus: c.get("eventBus") });
1229
+ const bookingId = c.req.param("id");
1230
+ const data = await parseJsonBody(c, overrideBookingStatusSchema);
1231
+ const auth = await authorizeBookingStatusMutation(c, {
1232
+ key: "override",
1233
+ actionName: "booking.status.override",
1234
+ routeOrToolName: "bookings.override-status",
1235
+ bookingId,
1236
+ commandInput: data,
1237
+ });
1238
+ if (!auth.allowed)
1239
+ return auth.response;
1240
+ const result = await bookingsService.overrideBookingStatus(c.get("db"), bookingId, data, c.get("userId"), bookingStatusMutationRuntime(c, auth));
442
1241
  if (result.status === "not_found") {
443
1242
  return c.json({ error: "Booking not found" }, 404);
444
1243
  }
@@ -505,23 +1304,52 @@ export const bookingRoutes = new Hono()
505
1304
  outcome: "denied",
506
1305
  reason: "traveler_not_found",
507
1306
  });
1307
+ await logBookingPiiReadActionLedger(c, {
1308
+ travelerId,
1309
+ status: "denied",
1310
+ reason: "traveler_not_found",
1311
+ routeOrToolName: "bookings.travelers.reveal",
1312
+ disclosureSummary: "Booking PII read denied before reveal",
1313
+ authorizationSource: auth.access?.authorizationSource,
1314
+ evaluatedRisk: auth.access?.evaluatedRisk,
1315
+ });
508
1316
  return c.json({ error: "Traveler not found" }, 404);
509
1317
  }
1318
+ let travelDetails;
510
1319
  try {
511
1320
  const pii = await createAuditedBookingPiiService(c, traveler.bookingId);
512
- const travelDetails = await pii.getTravelerTravelDetails(c.get("db"), traveler.id, c.get("userId"));
513
- await logBookingPiiAccess(c, {
514
- bookingId,
515
- travelerId,
516
- action: "read",
517
- outcome: "allowed",
518
- reason: "traveler_reveal",
519
- });
520
- return c.json({ data: { ...traveler, travelDetails } });
1321
+ travelDetails = await ledgerSensitiveRead(c.get("db"), {
1322
+ context: getActionLedgerRequestContext(c),
1323
+ actionName: BOOKING_PII_READ_ACTION_NAME,
1324
+ actionVersion: BOOKING_PII_READ_ACTION_VERSION,
1325
+ status: "succeeded",
1326
+ evaluatedRisk: auth.access?.evaluatedRisk ?? "high",
1327
+ targetType: "booking_traveler",
1328
+ targetId: traveler.id,
1329
+ routeOrToolName: "bookings.travelers.reveal",
1330
+ capabilityId: BOOKING_PII_READ_CAPABILITY.id,
1331
+ capabilityVersion: BOOKING_PII_READ_CAPABILITY.version,
1332
+ authorizationSource: auth.access?.authorizationSource ?? BOOKING_PII_AUTHORIZATION_SOURCE,
1333
+ reasonCode: "traveler_reveal",
1334
+ disclosedFieldSet: [
1335
+ ...TRAVELER_IDENTITY_DISCLOSED_FIELDS,
1336
+ ...TRAVELER_TRAVEL_DETAIL_DISCLOSED_FIELDS,
1337
+ ],
1338
+ disclosureSummary: "Traveler identity reveal",
1339
+ decisionPolicy: BOOKING_PII_DECISION_POLICY,
1340
+ }, () => pii.getTravelerTravelDetails(c.get("db"), traveler.id, c.get("userId")));
521
1341
  }
522
1342
  catch (error) {
523
1343
  return handleKmsConfigError(c, error);
524
1344
  }
1345
+ await logBookingPiiAccess(c, {
1346
+ bookingId,
1347
+ travelerId,
1348
+ action: "read",
1349
+ outcome: "allowed",
1350
+ reason: "traveler_reveal",
1351
+ });
1352
+ return c.json({ data: { ...traveler, travelDetails } });
525
1353
  })
526
1354
  .get("/:id/travelers/:travelerId/travel-details", async (c) => {
527
1355
  const auth = await authorizeBookingPiiAccess(c, {
@@ -541,29 +1369,72 @@ export const bookingRoutes = new Hono()
541
1369
  outcome: "denied",
542
1370
  reason: "participant_not_found",
543
1371
  });
1372
+ await logBookingPiiReadActionLedger(c, {
1373
+ travelerId: c.req.param("travelerId"),
1374
+ status: "denied",
1375
+ reason: "participant_not_found",
1376
+ routeOrToolName: "bookings.travelers.travel-details",
1377
+ disclosureSummary: "Booking PII read denied before reveal",
1378
+ authorizationSource: auth.access?.authorizationSource,
1379
+ evaluatedRisk: auth.access?.evaluatedRisk,
1380
+ });
544
1381
  return c.json({ error: "Traveler not found" }, 404);
545
1382
  }
1383
+ let details;
546
1384
  try {
547
1385
  const pii = await createAuditedBookingPiiService(c, traveler.bookingId);
548
- const details = await pii.getTravelerTravelDetails(c.get("db"), traveler.id, c.get("userId"));
549
- if (!details) {
550
- await logBookingPiiAccess(c, {
551
- bookingId: traveler.bookingId,
552
- travelerId: traveler.id,
553
- action: "read",
554
- outcome: "denied",
555
- reason: "travel_details_not_found",
556
- });
557
- return c.json({ error: "Traveler travel details not found" }, 404);
558
- }
559
- return c.json({ data: details });
1386
+ details = await ledgerSensitiveRead(c.get("db"), {
1387
+ context: getActionLedgerRequestContext(c),
1388
+ actionName: BOOKING_PII_READ_ACTION_NAME,
1389
+ actionVersion: BOOKING_PII_READ_ACTION_VERSION,
1390
+ status: "succeeded",
1391
+ evaluatedRisk: auth.access?.evaluatedRisk ?? "high",
1392
+ targetType: "booking_traveler",
1393
+ targetId: traveler.id,
1394
+ routeOrToolName: "bookings.travelers.travel-details",
1395
+ capabilityId: BOOKING_PII_READ_CAPABILITY.id,
1396
+ capabilityVersion: BOOKING_PII_READ_CAPABILITY.version,
1397
+ authorizationSource: auth.access?.authorizationSource ?? BOOKING_PII_AUTHORIZATION_SOURCE,
1398
+ reasonCode: "travel_details_reveal",
1399
+ disclosedFieldSet: TRAVELER_TRAVEL_DETAIL_DISCLOSED_FIELDS,
1400
+ disclosureSummary: "Traveler travel details reveal",
1401
+ decisionPolicy: BOOKING_PII_DECISION_POLICY,
1402
+ }, () => pii.getTravelerTravelDetails(c.get("db"), traveler.id, c.get("userId")));
560
1403
  }
561
1404
  catch (error) {
562
1405
  return handleKmsConfigError(c, error);
563
1406
  }
1407
+ if (!details) {
1408
+ await logBookingPiiAccess(c, {
1409
+ bookingId: traveler.bookingId,
1410
+ travelerId: traveler.id,
1411
+ action: "read",
1412
+ outcome: "denied",
1413
+ reason: "travel_details_not_found",
1414
+ });
1415
+ return c.json({ error: "Traveler travel details not found" }, 404);
1416
+ }
1417
+ return c.json({ data: details });
564
1418
  })
565
1419
  .post("/:id/travelers", async (c) => {
566
- const row = await bookingsService.createTraveler(c.get("db"), c.req.param("id"), await parseJsonBody(c, insertTravelerSchema), c.get("userId"));
1420
+ const body = await parseJsonBody(c, insertTravelerSchema);
1421
+ const ledgerContext = getActionLedgerRequestContext(c);
1422
+ const row = await c.get("db").transaction(async (tx) => {
1423
+ const txDb = tx;
1424
+ const row = await bookingsService.createTraveler(txDb, c.req.param("id"), body, c.get("userId"));
1425
+ if (!row)
1426
+ return null;
1427
+ await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
1428
+ action: "create",
1429
+ travelerId: row.id,
1430
+ changedFields: changedBookingTravelerFields(body, null, row),
1431
+ subject: "booking traveler",
1432
+ actionName: "booking.traveler.create",
1433
+ routeOrToolName: "bookings.travelers.create",
1434
+ evaluatedRisk: "high",
1435
+ });
1436
+ return row;
1437
+ });
567
1438
  if (!row) {
568
1439
  return c.json({ error: "Booking not found" }, 404);
569
1440
  }
@@ -582,13 +1453,34 @@ export const bookingRoutes = new Hono()
582
1453
  const runtime = getRouteRuntime(c);
583
1454
  const kms = await runtime.getKmsProvider();
584
1455
  const pii = await createAuditedBookingPiiService(c, c.req.param("id"));
585
- const result = await bookingsService.createTravelerWithTravelDetails(c.get("db"), c.req.param("id"), data, {
586
- pii,
587
- userId: c.get("userId"),
588
- actorId: c.get("userId"),
589
- resolveTravelSnapshot: runtime.resolveTravelSnapshot
590
- ? (personId) => runtime.resolveTravelSnapshot(c.get("db"), personId, { kms })
591
- : undefined,
1456
+ const ledgerContext = getActionLedgerRequestContext(c);
1457
+ const result = await c.get("db").transaction(async (tx) => {
1458
+ const txDb = tx;
1459
+ const result = await bookingsService.createTravelerWithTravelDetails(txDb, c.req.param("id"), data, {
1460
+ pii,
1461
+ userId: c.get("userId"),
1462
+ actorId: c.get("userId"),
1463
+ resolveTravelSnapshot: runtime.resolveTravelSnapshot
1464
+ ? (personId) => runtime.resolveTravelSnapshot(txDb, personId, { kms })
1465
+ : undefined,
1466
+ });
1467
+ if (!result)
1468
+ return null;
1469
+ await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
1470
+ action: "create",
1471
+ travelerId: result.traveler.id,
1472
+ changedFields: [
1473
+ ...new Set([
1474
+ ...changedBookingTravelerFields(data, null, result.traveler),
1475
+ ...changedBookingTravelDetailFields(data),
1476
+ ]),
1477
+ ].sort(),
1478
+ subject: "booking traveler with travel details",
1479
+ actionName: "booking.traveler_with_travel_details.create",
1480
+ routeOrToolName: "bookings.travelers.with-travel-details.create",
1481
+ evaluatedRisk: "high",
1482
+ });
1483
+ return result;
592
1484
  });
593
1485
  if (!result) {
594
1486
  return c.json({ error: "Booking not found" }, 404);
@@ -619,7 +1511,27 @@ export const bookingRoutes = new Hono()
619
1511
  }
620
1512
  const data = await parseJsonBody(c, updateTravelerWithTravelDetailsSchema);
621
1513
  const pii = await createAuditedBookingPiiService(c, bookingId);
622
- const result = await bookingsService.updateTravelerWithTravelDetails(c.get("db"), travelerId, data, { pii, actorId: c.get("userId") });
1514
+ const ledgerContext = getActionLedgerRequestContext(c);
1515
+ const result = await c.get("db").transaction(async (tx) => {
1516
+ const result = await bookingsService.updateTravelerWithTravelDetails(tx, travelerId, data, { pii, actorId: c.get("userId") });
1517
+ if (!result)
1518
+ return null;
1519
+ await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
1520
+ action: "update",
1521
+ travelerId: result.traveler.id,
1522
+ changedFields: [
1523
+ ...new Set([
1524
+ ...changedBookingTravelerFields(data, traveler, result.traveler),
1525
+ ...changedBookingTravelDetailFields(data),
1526
+ ]),
1527
+ ].sort(),
1528
+ subject: "booking traveler with travel details",
1529
+ actionName: "booking.traveler_with_travel_details.update",
1530
+ routeOrToolName: "bookings.travelers.with-travel-details.update",
1531
+ evaluatedRisk: "high",
1532
+ });
1533
+ return result;
1534
+ });
623
1535
  if (!result) {
624
1536
  return c.json({ error: "Traveler not found" }, 404);
625
1537
  }
@@ -651,7 +1563,23 @@ export const bookingRoutes = new Hono()
651
1563
  }
652
1564
  try {
653
1565
  const pii = await createAuditedBookingPiiService(c, traveler.bookingId);
654
- const row = await pii.upsertTravelerTravelDetails(c.get("db"), traveler.id, await parseJsonBody(c, upsertTravelerTravelDetailsSchema), c.get("userId"));
1566
+ const body = await parseJsonBody(c, upsertTravelerTravelDetailsSchema);
1567
+ const ledgerContext = getActionLedgerRequestContext(c);
1568
+ const row = await c.get("db").transaction(async (tx) => {
1569
+ const row = await pii.upsertTravelerTravelDetails(tx, traveler.id, body, c.get("userId"));
1570
+ if (!row)
1571
+ return null;
1572
+ await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
1573
+ action: "update",
1574
+ travelerId: traveler.id,
1575
+ changedFields: changedBookingTravelDetailFields(body),
1576
+ subject: "booking traveler travel details",
1577
+ actionName: "booking.traveler_travel_details.update",
1578
+ routeOrToolName: "bookings.travelers.travel-details.update",
1579
+ evaluatedRisk: "high",
1580
+ });
1581
+ return row;
1582
+ });
655
1583
  if (!row) {
656
1584
  return c.json({ error: "Traveler not found" }, 404);
657
1585
  }
@@ -662,7 +1590,29 @@ export const bookingRoutes = new Hono()
662
1590
  }
663
1591
  })
664
1592
  .patch("/:id/travelers/:travelerId", async (c) => {
665
- const row = await bookingsService.updateTraveler(c.get("db"), c.req.param("travelerId"), await parseJsonBody(c, updateTravelerSchema));
1593
+ const bookingId = c.req.param("id");
1594
+ const travelerId = c.req.param("travelerId");
1595
+ const before = await bookingsService.getTravelerRecordById(c.get("db"), bookingId, travelerId);
1596
+ if (!before) {
1597
+ return c.json({ error: "Traveler not found" }, 404);
1598
+ }
1599
+ const body = await parseJsonBody(c, updateTravelerSchema);
1600
+ const ledgerContext = getActionLedgerRequestContext(c);
1601
+ const row = await c.get("db").transaction(async (tx) => {
1602
+ const row = await bookingsService.updateTraveler(tx, travelerId, body);
1603
+ if (!row)
1604
+ return null;
1605
+ await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
1606
+ action: "update",
1607
+ travelerId: row.id,
1608
+ changedFields: changedBookingTravelerFields(body, before, row),
1609
+ subject: "booking traveler",
1610
+ actionName: "booking.traveler.update",
1611
+ routeOrToolName: "bookings.travelers.update",
1612
+ evaluatedRisk: "high",
1613
+ });
1614
+ return row;
1615
+ });
666
1616
  if (!row) {
667
1617
  return c.json({ error: "Traveler not found" }, 404);
668
1618
  }
@@ -690,7 +1640,22 @@ export const bookingRoutes = new Hono()
690
1640
  }
691
1641
  try {
692
1642
  const pii = await createAuditedBookingPiiService(c, traveler.bookingId);
693
- const row = await pii.deleteTravelerTravelDetails(c.get("db"), traveler.id, c.get("userId"));
1643
+ const ledgerContext = getActionLedgerRequestContext(c);
1644
+ const row = await c.get("db").transaction(async (tx) => {
1645
+ const row = await pii.deleteTravelerTravelDetails(tx, traveler.id, c.get("userId"));
1646
+ if (!row)
1647
+ return null;
1648
+ await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
1649
+ action: "delete",
1650
+ travelerId: traveler.id,
1651
+ changedFields: [],
1652
+ subject: "booking traveler travel details",
1653
+ actionName: "booking.traveler_travel_details.delete",
1654
+ routeOrToolName: "bookings.travelers.travel-details.delete",
1655
+ evaluatedRisk: "high",
1656
+ });
1657
+ return row;
1658
+ });
694
1659
  if (!row) {
695
1660
  return c.json({ error: "Traveler travel details not found" }, 404);
696
1661
  }
@@ -701,7 +1666,28 @@ export const bookingRoutes = new Hono()
701
1666
  }
702
1667
  })
703
1668
  .delete("/:id/travelers/:travelerId", async (c) => {
704
- const row = await bookingsService.deleteTraveler(c.get("db"), c.req.param("travelerId"));
1669
+ const bookingId = c.req.param("id");
1670
+ const travelerId = c.req.param("travelerId");
1671
+ const before = await bookingsService.getTravelerRecordById(c.get("db"), bookingId, travelerId);
1672
+ if (!before) {
1673
+ return c.json({ error: "Traveler not found" }, 404);
1674
+ }
1675
+ const ledgerContext = getActionLedgerRequestContext(c);
1676
+ const row = await c.get("db").transaction(async (tx) => {
1677
+ const row = await bookingsService.deleteTraveler(tx, travelerId);
1678
+ if (!row)
1679
+ return null;
1680
+ await appendBookingTravelerMutationLedgerEntryToDb(tx, ledgerContext, {
1681
+ action: "delete",
1682
+ travelerId,
1683
+ changedFields: [],
1684
+ subject: "booking traveler",
1685
+ actionName: "booking.traveler.delete",
1686
+ routeOrToolName: "bookings.travelers.delete",
1687
+ evaluatedRisk: "high",
1688
+ });
1689
+ return row;
1690
+ });
705
1691
  if (!row) {
706
1692
  return c.json({ error: "Traveler not found" }, 404);
707
1693
  }
@@ -716,7 +1702,25 @@ export const bookingRoutes = new Hono()
716
1702
  })
717
1703
  // 17. POST /:id/items — Add booking item
718
1704
  .post("/:id/items", async (c) => {
719
- const row = await bookingsService.createItem(c.get("db"), c.req.param("id"), await parseJsonBody(c, insertBookingItemSchema), c.get("userId"));
1705
+ const body = await parseJsonBody(c, insertBookingItemSchema);
1706
+ const ledgerContext = getActionLedgerRequestContext(c);
1707
+ const row = await c.get("db").transaction(async (tx) => {
1708
+ const row = await bookingsService.createItem(tx, c.req.param("id"), body, c.get("userId"));
1709
+ if (!row)
1710
+ return null;
1711
+ await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
1712
+ action: "create",
1713
+ actionName: "booking.item.create",
1714
+ actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
1715
+ targetType: "booking_item",
1716
+ targetId: row.id,
1717
+ changedFields: changedBookingItemFields(body, null, row),
1718
+ subject: "booking item",
1719
+ routeOrToolName: "bookings.items.create",
1720
+ evaluatedRisk: "high",
1721
+ });
1722
+ return row;
1723
+ });
720
1724
  if (!row) {
721
1725
  return c.json({ error: "Booking not found" }, 404);
722
1726
  }
@@ -724,7 +1728,31 @@ export const bookingRoutes = new Hono()
724
1728
  })
725
1729
  // 18. PATCH /:id/items/:itemId — Update booking item
726
1730
  .patch("/:id/items/:itemId", async (c) => {
727
- const row = await bookingsService.updateItem(c.get("db"), c.req.param("itemId"), await parseJsonBody(c, updateBookingItemSchema));
1731
+ const bookingId = c.req.param("id");
1732
+ const itemId = c.req.param("itemId");
1733
+ const before = (await bookingsService.listItems(c.get("db"), bookingId)).find((item) => item.id === itemId) ?? null;
1734
+ if (!before) {
1735
+ return c.json({ error: "Booking item not found" }, 404);
1736
+ }
1737
+ const body = await parseJsonBody(c, updateBookingItemSchema);
1738
+ const ledgerContext = getActionLedgerRequestContext(c);
1739
+ const row = await c.get("db").transaction(async (tx) => {
1740
+ const row = await bookingsService.updateItem(tx, itemId, body);
1741
+ if (!row)
1742
+ return null;
1743
+ await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
1744
+ action: "update",
1745
+ actionName: "booking.item.update",
1746
+ actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
1747
+ targetType: "booking_item",
1748
+ targetId: row.id,
1749
+ changedFields: changedBookingItemFields(body, before, row),
1750
+ subject: "booking item",
1751
+ routeOrToolName: "bookings.items.update",
1752
+ evaluatedRisk: "high",
1753
+ });
1754
+ return row;
1755
+ });
728
1756
  if (!row) {
729
1757
  return c.json({ error: "Booking item not found" }, 404);
730
1758
  }
@@ -732,7 +1760,31 @@ export const bookingRoutes = new Hono()
732
1760
  })
733
1761
  // 19. DELETE /:id/items/:itemId — Delete booking item
734
1762
  .delete("/:id/items/:itemId", async (c) => {
735
- const row = await bookingsService.deleteItem(c.get("db"), c.req.param("itemId"));
1763
+ const bookingId = c.req.param("id");
1764
+ const itemId = c.req.param("itemId");
1765
+ const before = (await bookingsService.listItems(c.get("db"), bookingId)).find((item) => item.id === itemId) ?? null;
1766
+ if (!before) {
1767
+ return c.json({ error: "Booking item not found" }, 404);
1768
+ }
1769
+ const ledgerContext = getActionLedgerRequestContext(c);
1770
+ const row = await c.get("db").transaction(async (tx) => {
1771
+ const row = await bookingsService.deleteItem(tx, itemId);
1772
+ if (!row)
1773
+ return null;
1774
+ await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
1775
+ action: "delete",
1776
+ actionName: "booking.item.delete",
1777
+ actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
1778
+ targetType: "booking_item",
1779
+ targetId: itemId,
1780
+ changedFields: [],
1781
+ subject: "booking item",
1782
+ routeOrToolName: "bookings.items.delete",
1783
+ evaluatedRisk: "high",
1784
+ summary: "Deleted booking item",
1785
+ });
1786
+ return row;
1787
+ });
736
1788
  if (!row) {
737
1789
  return c.json({ error: "Booking item not found" }, 404);
738
1790
  }
@@ -746,7 +1798,33 @@ export const bookingRoutes = new Hono()
746
1798
  })
747
1799
  // 21. POST /:id/items/:itemId/travelers — Link traveler to item
748
1800
  .post("/:id/items/:itemId/travelers", async (c) => {
749
- const row = await bookingsService.addItemParticipant(c.get("db"), c.req.param("itemId"), await parseJsonBody(c, insertBookingItemTravelerSchema));
1801
+ const bookingId = c.req.param("id");
1802
+ const itemId = c.req.param("itemId");
1803
+ const item = (await bookingsService.listItems(c.get("db"), bookingId)).find((row) => row.id === itemId) ??
1804
+ null;
1805
+ if (!item) {
1806
+ return c.json({ error: "Booking item not found" }, 404);
1807
+ }
1808
+ const body = await parseJsonBody(c, insertBookingItemTravelerSchema);
1809
+ const ledgerContext = getActionLedgerRequestContext(c);
1810
+ const row = await c.get("db").transaction(async (tx) => {
1811
+ const row = await bookingsService.addItemParticipant(tx, itemId, body);
1812
+ if (!row)
1813
+ return null;
1814
+ await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
1815
+ action: "create",
1816
+ actionName: "booking.item_traveler.create",
1817
+ actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
1818
+ targetType: "booking_item",
1819
+ targetId: itemId,
1820
+ changedFields: changedBookingItemFields(body, null, row),
1821
+ subject: "booking item traveler link",
1822
+ routeOrToolName: "bookings.items.travelers.create",
1823
+ evaluatedRisk: "high",
1824
+ summary: "Linked traveler to booking item",
1825
+ });
1826
+ return row;
1827
+ });
750
1828
  if (!row) {
751
1829
  return c.json({ error: "Booking item or traveler not found" }, 404);
752
1830
  }
@@ -754,7 +1832,37 @@ export const bookingRoutes = new Hono()
754
1832
  })
755
1833
  // 22. DELETE /:id/items/:itemId/travelers/:linkId — Unlink traveler from item
756
1834
  .delete("/:id/items/:itemId/travelers/:linkId", async (c) => {
757
- const row = await bookingsService.removeItemParticipant(c.get("db"), c.req.param("linkId"));
1835
+ const bookingId = c.req.param("id");
1836
+ const itemId = c.req.param("itemId");
1837
+ const linkId = c.req.param("linkId");
1838
+ const item = (await bookingsService.listItems(c.get("db"), bookingId)).find((row) => row.id === itemId) ??
1839
+ null;
1840
+ if (!item) {
1841
+ return c.json({ error: "Booking item not found" }, 404);
1842
+ }
1843
+ const before = (await bookingsService.listItemParticipants(c.get("db"), itemId)).find((link) => link.id === linkId) ?? null;
1844
+ if (!before) {
1845
+ return c.json({ error: "Booking item traveler link not found" }, 404);
1846
+ }
1847
+ const ledgerContext = getActionLedgerRequestContext(c);
1848
+ const row = await c.get("db").transaction(async (tx) => {
1849
+ const row = await bookingsService.removeItemParticipant(tx, linkId);
1850
+ if (!row)
1851
+ return null;
1852
+ await appendBookingMutationLedgerEntryToDb(tx, ledgerContext, {
1853
+ action: "delete",
1854
+ actionName: "booking.item_traveler.delete",
1855
+ actionVersion: BOOKING_ITEM_LEDGER_ACTION_VERSION,
1856
+ targetType: "booking_item",
1857
+ targetId: itemId,
1858
+ changedFields: [],
1859
+ subject: "booking item traveler link",
1860
+ routeOrToolName: "bookings.items.travelers.delete",
1861
+ evaluatedRisk: "high",
1862
+ summary: "Unlinked traveler from booking item",
1863
+ });
1864
+ return row;
1865
+ });
758
1866
  if (!row) {
759
1867
  return c.json({ error: "Booking item traveler link not found" }, 404);
760
1868
  }
@@ -839,10 +1947,23 @@ export const bookingRoutes = new Hono()
839
1947
  // 28. POST /:id/notes — Add note
840
1948
  .post("/:id/notes", async (c) => {
841
1949
  const userId = requireUserId(c);
842
- const row = await bookingsService.createNote(c.get("db"), c.req.param("id"), userId, await parseJsonBody(c, insertBookingNoteSchema));
1950
+ const bookingId = c.req.param("id");
1951
+ const row = await bookingsService.createNote(c.get("db"), bookingId, userId, await parseJsonBody(c, insertBookingNoteSchema));
843
1952
  if (!row) {
844
1953
  return c.json({ error: "Booking not found" }, 404);
845
1954
  }
1955
+ await appendBookingMutationLedgerEntry(c, {
1956
+ action: "create",
1957
+ actionName: "booking.note.create",
1958
+ actionVersion: BOOKING_NOTE_LEDGER_ACTION_VERSION,
1959
+ targetType: "booking",
1960
+ targetId: bookingId,
1961
+ changedFields: ["content"],
1962
+ subject: "booking note",
1963
+ routeOrToolName: "bookings.notes.create",
1964
+ evaluatedRisk: "medium",
1965
+ summary: "Created booking note",
1966
+ });
846
1967
  return c.json({ data: row }, 201);
847
1968
  })
848
1969
  // 28b. DELETE /:id/notes/:noteId — Delete note
@@ -851,6 +1972,18 @@ export const bookingRoutes = new Hono()
851
1972
  if (!row) {
852
1973
  return c.json({ error: "Note not found" }, 404);
853
1974
  }
1975
+ await appendBookingMutationLedgerEntry(c, {
1976
+ action: "delete",
1977
+ actionName: "booking.note.delete",
1978
+ actionVersion: BOOKING_NOTE_LEDGER_ACTION_VERSION,
1979
+ targetType: "booking",
1980
+ targetId: c.req.param("id"),
1981
+ changedFields: [],
1982
+ subject: "booking note",
1983
+ routeOrToolName: "bookings.notes.delete",
1984
+ evaluatedRisk: "medium",
1985
+ summary: "Deleted booking note",
1986
+ });
854
1987
  return c.json({ success: true }, 200);
855
1988
  })
856
1989
  // ==========================================================================
@@ -880,3 +2013,12 @@ export const bookingRoutes = new Hono()
880
2013
  // Booking Groups (shared-room / split-booking model)
881
2014
  // ==========================================================================
882
2015
  .route("/groups", bookingGroupRoutes);
2016
+ export const __test__ = {
2017
+ bookingActionLedgerQuerySchema,
2018
+ bookingMutationSummary,
2019
+ buildBookingActionLedgerPage,
2020
+ changedBookingItemFields,
2021
+ changedBookingMutationFields,
2022
+ changedBookingTravelDetailFields,
2023
+ changedBookingTravelerFields,
2024
+ };