@voyant-travel/apps 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/access-boundary.d.ts +2 -0
  2. package/dist/api-runtime.d.ts +23 -11
  3. package/dist/api-runtime.js +40 -1
  4. package/dist/api-runtime.test.d.ts +1 -0
  5. package/dist/api-runtime.test.js +58 -0
  6. package/dist/app-api-contracts.d.ts +58 -8
  7. package/dist/app-api-contracts.js +102 -9
  8. package/dist/app-api-finance-routes.test.js +482 -2
  9. package/dist/app-api-routes.d.ts +6 -5
  10. package/dist/app-api-routes.js +131 -22
  11. package/dist/app-api-service.d.ts +33 -1
  12. package/dist/app-api-service.js +162 -0
  13. package/dist/compiler.test.js +7 -0
  14. package/dist/consent.d.ts +1 -0
  15. package/dist/consent.js +2 -2
  16. package/dist/contracts.d.ts +2 -26
  17. package/dist/contracts.js +4 -11
  18. package/dist/installation-service.d.ts +27 -3
  19. package/dist/installation-service.js +72 -3
  20. package/dist/oauth-installation.d.ts +101 -0
  21. package/dist/oauth-installation.js +143 -0
  22. package/dist/oauth-service.d.ts +9 -1
  23. package/dist/oauth-service.js +111 -97
  24. package/dist/oauth-service.test.js +121 -2
  25. package/dist/routes-openapi.d.ts +1 -25
  26. package/dist/routes-viewer-scopes.test.d.ts +1 -0
  27. package/dist/routes-viewer-scopes.test.js +46 -0
  28. package/dist/routes.d.ts +8 -5
  29. package/dist/routes.js +43 -24
  30. package/dist/runtime-contributor.d.ts +13 -1
  31. package/dist/runtime-contributor.js +9 -1
  32. package/dist/runtime-contributor.test.d.ts +1 -0
  33. package/dist/runtime-contributor.test.js +55 -0
  34. package/dist/runtime-port.d.ts +29 -0
  35. package/dist/runtime-port.js +28 -0
  36. package/dist/schema.d.ts +170 -0
  37. package/dist/schema.js +18 -0
  38. package/dist/session-token-service.d.ts +4 -0
  39. package/dist/session-token-service.js +90 -28
  40. package/dist/session-token-service.test.d.ts +1 -0
  41. package/dist/session-token-service.test.js +187 -0
  42. package/dist/session-token.d.ts +13 -2
  43. package/dist/session-token.js +0 -0
  44. package/dist/session-token.test.js +51 -0
  45. package/dist/voyant.js +95 -1
  46. package/migrations/20260718205748_managed_installation_binding.sql +16 -0
  47. package/migrations/meta/_journal.json +8 -1
  48. package/package.json +10 -5
@@ -1,3 +1,4 @@
1
+ import { handleApiError } from "@voyant-travel/hono";
1
2
  import { Hono } from "hono";
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
  import { createAppsAppApiRoutes } from "./app-api-routes.js";
@@ -5,6 +6,8 @@ import { appGrants, appInstallations, appReleases } from "./schema.js";
5
6
  function accessDb(scopes = ["finance-documents:read"]) {
6
7
  const db = Object.create(null);
7
8
  Object.assign(db, {
9
+ transaction: (callback) => callback(db),
10
+ insert: () => ({ values: async () => undefined }),
8
11
  select: () => ({
9
12
  from: (table) => ({
10
13
  where: () => {
@@ -46,9 +49,38 @@ function accessDb(scopes = ["finance-documents:read"]) {
46
49
  });
47
50
  return db;
48
51
  }
52
+ function issuanceDocument(id) {
53
+ return {
54
+ id,
55
+ documentType: "invoice",
56
+ number: "INV-1",
57
+ status: "issued",
58
+ booking: { id: "booking_1", number: "B-1" },
59
+ billing: {
60
+ name: "Example Customer",
61
+ email: null,
62
+ phone: null,
63
+ address: null,
64
+ city: null,
65
+ region: null,
66
+ country: null,
67
+ vatCode: null,
68
+ registrationNumber: null,
69
+ },
70
+ currency: { document: "EUR", base: null },
71
+ fx: null,
72
+ totals: { subtotalCents: 1000, taxCents: 0, totalCents: 1000 },
73
+ dates: { issuedOn: "2026-07-18", dueOn: null },
74
+ language: "en",
75
+ taxRegime: null,
76
+ series: null,
77
+ allocation: { required: false, pending: false, placeholderNumber: null },
78
+ lines: [],
79
+ };
80
+ }
49
81
  describe("finance App API routes", () => {
50
82
  it("hydrates a finance issuance document by stable id", async () => {
51
- const getIssuanceDocument = vi.fn().mockResolvedValue({ id: "inv_1" });
83
+ const getIssuanceDocument = vi.fn().mockResolvedValue(issuanceDocument("inv_1"));
52
84
  const app = new Hono();
53
85
  app.use("*", async (c, next) => {
54
86
  c.set("db", accessDb());
@@ -63,7 +95,37 @@ describe("finance App API routes", () => {
63
95
  app.route("/", createAppsAppApiRoutes({ finance: { getIssuanceDocument } }));
64
96
  const response = await app.request("/v1/app/finance/documents/inv_1");
65
97
  expect(response.status).toBe(200);
66
- await expect(response.json()).resolves.toEqual({ data: { id: "inv_1" } });
98
+ await expect(response.json()).resolves.toEqual({ data: issuanceDocument("inv_1") });
99
+ });
100
+ it("fails closed when an online extension token targets another finance document", async () => {
101
+ const getIssuanceDocument = vi.fn(async (_db, documentId) => issuanceDocument(documentId));
102
+ const app = new Hono();
103
+ app.onError((error, c) => handleApiError(error, c));
104
+ app.use("*", async (c, next) => {
105
+ c.set("db", accessDb());
106
+ c.set("callerType", "app");
107
+ c.set("appId", "app_1");
108
+ c.set("appInstallationId", "inst_1");
109
+ c.set("appReleaseId", "rel_1");
110
+ c.set("appTokenMode", "online");
111
+ c.set("appContextConstraint", {
112
+ entity: { type: "invoice", id: "inv_1" },
113
+ slot: "invoice.details.after-summary",
114
+ });
115
+ c.set("scopes", ["finance-documents:read"]);
116
+ await next();
117
+ });
118
+ app.route("/", createAppsAppApiRoutes({ finance: { getIssuanceDocument } }));
119
+ const allowed = await app.request("/v1/app/finance/documents/inv_1");
120
+ expect(allowed.status).toBe(200);
121
+ expect(getIssuanceDocument).toHaveBeenCalledWith(expect.anything(), "inv_1");
122
+ getIssuanceDocument.mockClear();
123
+ const response = await app.request("/v1/app/finance/documents/inv_other");
124
+ expect(response.status).toBe(403);
125
+ await expect(response.json()).resolves.toMatchObject({
126
+ code: "app_api_entity_context_mismatch",
127
+ });
128
+ expect(getIssuanceDocument).not.toHaveBeenCalled();
67
129
  });
68
130
  it("does not expose a caller-selected provider reference route", async () => {
69
131
  const getExternalReference = vi.fn().mockResolvedValue({ id: "ref_1" });
@@ -150,4 +212,422 @@ describe("finance App API routes", () => {
150
212
  action: "finance.external-reference.upserted",
151
213
  }));
152
214
  });
215
+ it("attaches a bounded PDF and returns an app-safe rendition URL", async () => {
216
+ const scopes = ["finance-document-artifacts:write"];
217
+ const attachPdfArtifact = vi.fn().mockResolvedValue({
218
+ status: "ok",
219
+ outcome: "created",
220
+ artifact: {
221
+ id: "rend_1",
222
+ documentId: "inv_1",
223
+ provider: "app_1",
224
+ fileName: "invoice.pdf",
225
+ byteSize: 9,
226
+ checksum: "checksum",
227
+ storageKey: "must-not-cross-app-api-boundary",
228
+ createdAt: "2026-07-18T09:00:00.000Z",
229
+ },
230
+ });
231
+ const app = new Hono();
232
+ app.use("*", async (c, next) => {
233
+ c.set("db", accessDb(scopes));
234
+ c.set("callerType", "app");
235
+ c.set("appId", "app_1");
236
+ c.set("appInstallationId", "inst_1");
237
+ c.set("appReleaseId", "rel_1");
238
+ c.set("appTokenMode", "offline");
239
+ c.set("scopes", scopes);
240
+ await next();
241
+ });
242
+ app.route("/", createAppsAppApiRoutes({ finance: { attachPdfArtifact } }));
243
+ const response = await app.request("https://operator.example/v1/app/finance/documents/inv_1/artifacts/provider-pdf", {
244
+ method: "PUT",
245
+ headers: {
246
+ "content-type": "application/pdf",
247
+ "idempotency-key": "pdf:operation-1",
248
+ "x-voyant-artifact-name": "invoice.pdf",
249
+ },
250
+ body: "%PDF-test",
251
+ }, { API_BASE_URL: "https://operator.example/api/" });
252
+ expect(response.status).toBe(200);
253
+ const payload = await response.json();
254
+ expect(payload).toEqual({
255
+ data: {
256
+ outcome: "created",
257
+ artifact: expect.objectContaining({
258
+ id: "rend_1",
259
+ documentUrl: "https://operator.example/api/v1/admin/finance/invoice-renditions/rend_1/download",
260
+ }),
261
+ },
262
+ });
263
+ expect(JSON.stringify(payload)).not.toContain("storageKey");
264
+ expect(attachPdfArtifact).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ API_BASE_URL: "https://operator.example/api/" }), "inv_1", "app_1", expect.objectContaining({ idempotencyKey: "pdf:operation-1" }));
265
+ });
266
+ it("rejects unsupported artifact content before invoking Finance", async () => {
267
+ const attachPdfArtifact = vi.fn();
268
+ const app = new Hono();
269
+ app.onError((error, c) => handleApiError(error, c));
270
+ app.use("*", async (c, next) => {
271
+ c.set("db", accessDb(["finance-document-artifacts:write"]));
272
+ c.set("callerType", "app");
273
+ c.set("appId", "app_1");
274
+ c.set("appInstallationId", "inst_1");
275
+ c.set("appReleaseId", "rel_1");
276
+ c.set("appTokenMode", "offline");
277
+ c.set("scopes", ["finance-document-artifacts:write"]);
278
+ await next();
279
+ });
280
+ app.route("/", createAppsAppApiRoutes({ finance: { attachPdfArtifact } }));
281
+ const response = await app.request("/v1/app/finance/documents/inv_1/artifacts/provider-pdf", {
282
+ method: "PUT",
283
+ headers: {
284
+ "content-type": "text/plain",
285
+ "idempotency-key": "pdf:operation-1",
286
+ "x-voyant-artifact-name": "invoice.pdf",
287
+ },
288
+ body: "not a pdf",
289
+ });
290
+ expect(response.status).toBe(415);
291
+ expect(attachPdfArtifact).not.toHaveBeenCalled();
292
+ });
293
+ it("stops reading an artifact stream when its actual bytes exceed the limit", async () => {
294
+ const attachPdfArtifact = vi.fn();
295
+ const app = new Hono();
296
+ app.onError((error, c) => handleApiError(error, c));
297
+ app.use("*", async (c, next) => {
298
+ c.set("db", accessDb(["finance-document-artifacts:write"]));
299
+ c.set("callerType", "app");
300
+ c.set("appId", "app_1");
301
+ c.set("appInstallationId", "inst_1");
302
+ c.set("appReleaseId", "rel_1");
303
+ c.set("appTokenMode", "offline");
304
+ c.set("scopes", ["finance-document-artifacts:write"]);
305
+ await next();
306
+ });
307
+ app.route("/", createAppsAppApiRoutes({ finance: { attachPdfArtifact }, maxFinanceArtifactBytes: 8 }));
308
+ const response = await app.request("/v1/app/finance/documents/inv_1/artifacts/provider-pdf", {
309
+ method: "PUT",
310
+ headers: {
311
+ "content-type": "application/pdf",
312
+ "idempotency-key": "pdf:operation-1",
313
+ "x-voyant-artifact-name": "invoice.pdf",
314
+ },
315
+ body: "%PDF-test",
316
+ });
317
+ expect(response.status).toBe(413);
318
+ expect(attachPdfArtifact).not.toHaveBeenCalled();
319
+ });
320
+ it("rejects artifact writes whose token does not match the deployment installation", async () => {
321
+ const scopes = ["finance-document-artifacts:write"];
322
+ const attachPdfArtifact = vi.fn();
323
+ const app = new Hono();
324
+ app.onError((error, c) => handleApiError(error, c));
325
+ app.use("*", async (c, next) => {
326
+ c.set("db", accessDb(scopes));
327
+ c.set("callerType", "app");
328
+ c.set("appId", "app_from_another_deployment");
329
+ c.set("appInstallationId", "inst_1");
330
+ c.set("appReleaseId", "rel_1");
331
+ c.set("appTokenMode", "offline");
332
+ c.set("scopes", scopes);
333
+ await next();
334
+ });
335
+ app.route("/", createAppsAppApiRoutes({ finance: { attachPdfArtifact } }));
336
+ const response = await app.request("/v1/app/finance/documents/inv_1/artifacts/provider-pdf", {
337
+ method: "PUT",
338
+ headers: {
339
+ "content-type": "application/pdf",
340
+ "idempotency-key": "pdf:operation-1",
341
+ "x-voyant-artifact-name": "invoice.pdf",
342
+ },
343
+ body: "%PDF-test",
344
+ });
345
+ expect(response.status).toBe(403);
346
+ expect(attachPdfArtifact).not.toHaveBeenCalled();
347
+ });
348
+ it("reports ordered provider-neutral external sync state", async () => {
349
+ const scopes = ["finance-external-sync:write"];
350
+ const updateExternalSyncState = vi.fn().mockResolvedValue({
351
+ status: "ok",
352
+ outcome: "updated",
353
+ sync: {
354
+ provider: "app_1",
355
+ documentId: "inv_1",
356
+ operationId: "operation-1",
357
+ status: "retryable_failure",
358
+ occurredAt: "2026-07-18T09:00:00.000Z",
359
+ error: { code: "provider_timeout", message: "Provider timed out." },
360
+ metadata: null,
361
+ },
362
+ });
363
+ const app = new Hono();
364
+ app.use("*", async (c, next) => {
365
+ c.set("db", accessDb(scopes));
366
+ c.set("callerType", "app");
367
+ c.set("appId", "app_1");
368
+ c.set("appInstallationId", "inst_1");
369
+ c.set("appReleaseId", "rel_1");
370
+ c.set("appTokenMode", "offline");
371
+ c.set("scopes", scopes);
372
+ await next();
373
+ });
374
+ app.route("/", createAppsAppApiRoutes({ finance: { updateExternalSyncState } }));
375
+ const response = await app.request("/v1/app/finance/documents/inv_1/external-sync-state", {
376
+ method: "PUT",
377
+ headers: { "content-type": "application/json" },
378
+ body: JSON.stringify({
379
+ operationId: "operation-1",
380
+ status: "retryable_failure",
381
+ occurredAt: "2026-07-18T09:00:00.000Z",
382
+ error: { code: "provider_timeout", message: "Provider timed out." },
383
+ metadata: null,
384
+ }),
385
+ });
386
+ expect(response.status).toBe(200);
387
+ expect(updateExternalSyncState).toHaveBeenCalledWith(expect.anything(), "inv_1", "app_1", expect.objectContaining({ operationId: "operation-1" }));
388
+ });
389
+ it("records a converted document lifecycle observation with explicit lineage", async () => {
390
+ const scopes = ["finance-external-lifecycle:write"];
391
+ const updateExternalLifecycleState = vi.fn().mockResolvedValue({
392
+ status: "ok",
393
+ outcome: "created",
394
+ lifecycle: {
395
+ provider: "app_1",
396
+ documentId: "proforma_1",
397
+ operationId: "conversion-1",
398
+ state: "converted",
399
+ occurredAt: "2026-07-18T10:00:00.000Z",
400
+ lineage: {
401
+ sourceDocumentId: "proforma_1",
402
+ successorDocumentId: "invoice_1",
403
+ },
404
+ },
405
+ });
406
+ const app = new Hono();
407
+ app.use("*", async (c, next) => {
408
+ c.set("db", accessDb(scopes));
409
+ c.set("callerType", "app");
410
+ c.set("appId", "app_1");
411
+ c.set("appInstallationId", "inst_1");
412
+ c.set("appReleaseId", "rel_1");
413
+ c.set("appTokenMode", "offline");
414
+ c.set("scopes", scopes);
415
+ await next();
416
+ });
417
+ app.route("/", createAppsAppApiRoutes({ finance: { updateExternalLifecycleState } }));
418
+ const response = await app.request("/v1/app/finance/documents/proforma_1/external-lifecycle-state", {
419
+ method: "PUT",
420
+ headers: { "content-type": "application/json" },
421
+ body: JSON.stringify({
422
+ operationId: "conversion-1",
423
+ state: "converted",
424
+ occurredAt: "2026-07-18T10:00:00.000Z",
425
+ lineage: {
426
+ sourceDocumentId: "proforma_1",
427
+ successorDocumentId: "invoice_1",
428
+ },
429
+ }),
430
+ });
431
+ expect(response.status).toBe(200);
432
+ expect(updateExternalLifecycleState).toHaveBeenCalledWith(expect.anything(), "proforma_1", "app_1", expect.objectContaining({ state: "converted" }));
433
+ });
434
+ it("records a provider-neutral settlement observation without a payment mutation", async () => {
435
+ const scopes = ["finance-settlement-observations:write"];
436
+ const recordSettlementObservation = vi.fn().mockResolvedValue({
437
+ status: "ok",
438
+ outcome: "created",
439
+ observation: {
440
+ provider: "app_1",
441
+ documentId: "invoice_1",
442
+ operationId: "settlement-1",
443
+ occurredAt: "2026-07-18T11:00:00.000Z",
444
+ status: "paid",
445
+ currency: "EUR",
446
+ totals: { totalCents: 1000, paidCents: 1000, balanceDueCents: 0 },
447
+ paymentIdentifiers: ["payment-1"],
448
+ },
449
+ });
450
+ const app = new Hono();
451
+ app.use("*", async (c, next) => {
452
+ c.set("db", accessDb(scopes));
453
+ c.set("callerType", "app");
454
+ c.set("appId", "app_1");
455
+ c.set("appInstallationId", "inst_1");
456
+ c.set("appReleaseId", "rel_1");
457
+ c.set("appTokenMode", "offline");
458
+ c.set("scopes", scopes);
459
+ await next();
460
+ });
461
+ app.route("/", createAppsAppApiRoutes({ finance: { recordSettlementObservation } }));
462
+ const response = await app.request("/v1/app/finance/documents/invoice_1/settlement-observations", {
463
+ method: "POST",
464
+ headers: { "content-type": "application/json" },
465
+ body: JSON.stringify({
466
+ operationId: "settlement-1",
467
+ occurredAt: "2026-07-18T11:00:00.000Z",
468
+ status: "paid",
469
+ currency: "EUR",
470
+ totals: { totalCents: 1000, paidCents: 1000, balanceDueCents: 0 },
471
+ paymentIdentifiers: ["payment-1"],
472
+ }),
473
+ });
474
+ expect(response.status).toBe(201);
475
+ expect(recordSettlementObservation).toHaveBeenCalledWith(expect.anything(), "invoice_1", "app_1", expect.objectContaining({ paymentIdentifiers: ["payment-1"] }));
476
+ });
477
+ it("enforces the signed entity constraint on lifecycle and settlement writes", async () => {
478
+ const scopes = ["finance-external-lifecycle:write", "finance-settlement-observations:write"];
479
+ const updateExternalLifecycleState = vi.fn();
480
+ const recordSettlementObservation = vi.fn();
481
+ const app = new Hono();
482
+ app.onError((error, c) => handleApiError(error, c));
483
+ app.use("*", async (c, next) => {
484
+ c.set("db", accessDb(scopes));
485
+ c.set("callerType", "app");
486
+ c.set("appId", "app_1");
487
+ c.set("appInstallationId", "inst_1");
488
+ c.set("appReleaseId", "rel_1");
489
+ c.set("appTokenMode", "online");
490
+ c.set("appContextConstraint", {
491
+ entity: { type: "invoice", id: "invoice_1" },
492
+ slot: "invoice.details.after-summary",
493
+ });
494
+ c.set("scopes", scopes);
495
+ await next();
496
+ });
497
+ app.route("/", createAppsAppApiRoutes({
498
+ finance: { updateExternalLifecycleState, recordSettlementObservation },
499
+ }));
500
+ const lifecycle = await app.request("/v1/app/finance/documents/invoice_other/external-lifecycle-state", {
501
+ method: "PUT",
502
+ headers: { "content-type": "application/json" },
503
+ body: JSON.stringify({
504
+ operationId: "void-1",
505
+ state: "voided",
506
+ occurredAt: "2026-07-18T10:00:00.000Z",
507
+ lineage: null,
508
+ }),
509
+ });
510
+ const settlement = await app.request("/v1/app/finance/documents/invoice_other/settlement-observations", {
511
+ method: "POST",
512
+ headers: { "content-type": "application/json" },
513
+ body: JSON.stringify({
514
+ operationId: "settlement-1",
515
+ occurredAt: "2026-07-18T11:00:00.000Z",
516
+ status: "paid",
517
+ currency: "EUR",
518
+ totals: { totalCents: 1000, paidCents: 1000, balanceDueCents: 0 },
519
+ paymentIdentifiers: ["payment-1"],
520
+ }),
521
+ });
522
+ expect(lifecycle.status).toBe(403);
523
+ expect(settlement.status).toBe(403);
524
+ expect(updateExternalLifecycleState).not.toHaveBeenCalled();
525
+ expect(recordSettlementObservation).not.toHaveBeenCalled();
526
+ });
527
+ it("returns stable public conflicts for reused lifecycle and settlement operations", async () => {
528
+ const scopes = ["finance-external-lifecycle:write", "finance-settlement-observations:write"];
529
+ const updateExternalLifecycleState = vi.fn().mockResolvedValue({
530
+ status: "conflict",
531
+ reason: "idempotency_key_reused",
532
+ current: null,
533
+ });
534
+ const recordSettlementObservation = vi.fn().mockResolvedValue({
535
+ status: "conflict",
536
+ reason: "settlement_regression",
537
+ current: null,
538
+ });
539
+ const app = new Hono();
540
+ app.onError((error, c) => handleApiError(error, c));
541
+ app.use("*", async (c, next) => {
542
+ c.set("db", accessDb(scopes));
543
+ c.set("callerType", "app");
544
+ c.set("appId", "app_1");
545
+ c.set("appInstallationId", "inst_1");
546
+ c.set("appReleaseId", "rel_1");
547
+ c.set("appTokenMode", "offline");
548
+ c.set("scopes", scopes);
549
+ await next();
550
+ });
551
+ app.route("/", createAppsAppApiRoutes({
552
+ finance: { updateExternalLifecycleState, recordSettlementObservation },
553
+ }));
554
+ const lifecycle = await app.request("/v1/app/finance/documents/invoice_1/external-lifecycle-state", {
555
+ method: "PUT",
556
+ headers: { "content-type": "application/json" },
557
+ body: JSON.stringify({
558
+ operationId: "void-1",
559
+ state: "voided",
560
+ occurredAt: "2026-07-18T10:00:00.000Z",
561
+ lineage: null,
562
+ }),
563
+ });
564
+ const settlement = await app.request("/v1/app/finance/documents/invoice_1/settlement-observations", {
565
+ method: "POST",
566
+ headers: { "content-type": "application/json" },
567
+ body: JSON.stringify({
568
+ operationId: "settlement-2",
569
+ occurredAt: "2026-07-18T12:00:00.000Z",
570
+ status: "partial",
571
+ currency: "EUR",
572
+ totals: { totalCents: 1000, paidCents: 500, balanceDueCents: 500 },
573
+ paymentIdentifiers: ["payment-1"],
574
+ }),
575
+ });
576
+ expect(lifecycle.status).toBe(409);
577
+ await expect(lifecycle.json()).resolves.toMatchObject({
578
+ code: "app_api_finance_external_lifecycle_idempotency_key_reused",
579
+ });
580
+ expect(settlement.status).toBe(409);
581
+ await expect(settlement.json()).resolves.toMatchObject({
582
+ code: "app_api_finance_settlement_observation_settlement_regression",
583
+ });
584
+ });
585
+ it("denies lifecycle and settlement writes across installation boundaries", async () => {
586
+ const scopes = ["finance-external-lifecycle:write", "finance-settlement-observations:write"];
587
+ const updateExternalLifecycleState = vi.fn();
588
+ const recordSettlementObservation = vi.fn();
589
+ const app = new Hono();
590
+ app.onError((error, c) => handleApiError(error, c));
591
+ app.use("*", async (c, next) => {
592
+ c.set("db", accessDb(scopes));
593
+ c.set("callerType", "app");
594
+ c.set("appId", "app_from_another_deployment");
595
+ c.set("appInstallationId", "inst_1");
596
+ c.set("appReleaseId", "rel_1");
597
+ c.set("appTokenMode", "offline");
598
+ c.set("scopes", scopes);
599
+ await next();
600
+ });
601
+ app.route("/", createAppsAppApiRoutes({
602
+ finance: { updateExternalLifecycleState, recordSettlementObservation },
603
+ }));
604
+ const requests = [
605
+ app.request("/v1/app/finance/documents/invoice_1/external-lifecycle-state", {
606
+ method: "PUT",
607
+ headers: { "content-type": "application/json" },
608
+ body: JSON.stringify({
609
+ operationId: "void-1",
610
+ state: "voided",
611
+ occurredAt: "2026-07-18T10:00:00.000Z",
612
+ lineage: null,
613
+ }),
614
+ }),
615
+ app.request("/v1/app/finance/documents/invoice_1/settlement-observations", {
616
+ method: "POST",
617
+ headers: { "content-type": "application/json" },
618
+ body: JSON.stringify({
619
+ operationId: "settlement-1",
620
+ occurredAt: "2026-07-18T11:00:00.000Z",
621
+ status: "paid",
622
+ currency: "EUR",
623
+ totals: { totalCents: 1000, paidCents: 1000, balanceDueCents: 0 },
624
+ paymentIdentifiers: ["payment-1"],
625
+ }),
626
+ }),
627
+ ];
628
+ for (const response of await Promise.all(requests))
629
+ expect(response.status).toBe(403);
630
+ expect(updateExternalLifecycleState).not.toHaveBeenCalled();
631
+ expect(recordSettlementObservation).not.toHaveBeenCalled();
632
+ });
153
633
  });
@@ -1,8 +1,11 @@
1
- import type { AccessCatalog } from "@voyant-travel/types/api-keys";
1
+ import type { VoyantAppContextConstraint } from "@voyant-travel/core";
2
2
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
3
  import { Hono } from "hono";
4
4
  import { type AppApiServiceOptions } from "./app-api-service.js";
5
5
  type Env = {
6
+ Bindings: {
7
+ API_BASE_URL?: string;
8
+ };
6
9
  Variables: {
7
10
  db: PostgresJsDatabase;
8
11
  appId?: string;
@@ -10,6 +13,7 @@ type Env = {
10
13
  appReleaseId?: string;
11
14
  appTokenMode?: "offline" | "online";
12
15
  appViewerId?: string;
16
+ appContextConstraint?: VoyantAppContextConstraint;
13
17
  callerType?: string;
14
18
  scopes?: string[];
15
19
  };
@@ -17,12 +21,9 @@ type Env = {
17
21
  /** Absolute mount prefix for the versioned App API surface. */
18
22
  export declare const APP_API_BASE_PATH = "/v1/app";
19
23
  export interface AppsAppApiRouteOptions extends AppApiServiceOptions {
20
- oauth?: {
21
- accessCatalog: AccessCatalog;
22
- deploymentId: string;
23
- };
24
24
  deadlineMs?: number;
25
25
  maxPayloadBytes?: number;
26
+ maxFinanceArtifactBytes?: number;
26
27
  }
27
28
  export declare function createAppsAppApiRoutes(options?: AppsAppApiRouteOptions): Hono<Env, import("hono/types").BlankSchema, "/">;
28
29
  export {};