@voyant-travel/apps 0.6.3 → 0.8.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 (40) hide show
  1. package/dist/api-runtime.d.ts +23 -11
  2. package/dist/api-runtime.js +44 -2
  3. package/dist/api-runtime.test.d.ts +1 -0
  4. package/dist/api-runtime.test.js +54 -0
  5. package/dist/app-api-contracts.d.ts +84 -7
  6. package/dist/app-api-contracts.js +130 -9
  7. package/dist/app-api-finance-routes.test.d.ts +1 -0
  8. package/dist/app-api-finance-routes.test.js +633 -0
  9. package/dist/app-api-routes.d.ts +6 -5
  10. package/dist/app-api-routes.js +144 -22
  11. package/dist/app-api-service.d.ts +49 -4
  12. package/dist/app-api-service.js +237 -0
  13. package/dist/app-api-service.test.js +52 -0
  14. package/dist/consent.d.ts +1 -0
  15. package/dist/consent.js +2 -2
  16. package/dist/contracts.d.ts +4 -28
  17. package/dist/contracts.js +3 -10
  18. package/dist/oauth-service.d.ts +6 -1
  19. package/dist/oauth-service.js +41 -4
  20. package/dist/oauth-service.test.js +39 -2
  21. package/dist/routes-openapi.d.ts +1 -25
  22. package/dist/routes-viewer-scopes.test.d.ts +1 -0
  23. package/dist/routes-viewer-scopes.test.js +46 -0
  24. package/dist/routes.d.ts +5 -5
  25. package/dist/routes.js +41 -24
  26. package/dist/runtime-contributor.d.ts +9 -1
  27. package/dist/runtime-contributor.js +25 -2
  28. package/dist/runtime-contributor.test.d.ts +1 -0
  29. package/dist/runtime-contributor.test.js +52 -0
  30. package/dist/runtime-port.d.ts +8 -0
  31. package/dist/runtime-port.js +22 -0
  32. package/dist/session-token-service.d.ts +2 -0
  33. package/dist/session-token-service.js +44 -27
  34. package/dist/session-token-service.test.d.ts +1 -0
  35. package/dist/session-token-service.test.js +157 -0
  36. package/dist/session-token.d.ts +5 -1
  37. package/dist/session-token.js +0 -0
  38. package/dist/session-token.test.js +5 -0
  39. package/dist/voyant.js +136 -1
  40. package/package.json +9 -3
@@ -1,9 +1,8 @@
1
1
  import { ApiHttpError, parseJsonBody, parseQuery, RequestValidationError, } from "@voyant-travel/hono";
2
2
  import { Hono } from "hono";
3
3
  import { z } from "zod";
4
- import { appApiAuditQuerySchema, appApiCustomFieldDefinitionCreateSchema, appApiCustomFieldDefinitionListQuerySchema, appApiCustomFieldDefinitionUpdateSchema, appApiCustomFieldValueListQuerySchema, appApiCustomFieldValueUpsertSchema, appApiEntityReadQuerySchema, appApiFinanceActionSchema, appApiFinanceDocumentQuerySchema, appApiTokenExchangeSchema, appApiVersionHeader, appApiWebhookReplaySchema, } from "./app-api-contracts.js";
4
+ import { appApiAuditQuerySchema, appApiCustomFieldDefinitionCreateSchema, appApiCustomFieldDefinitionListQuerySchema, appApiCustomFieldDefinitionUpdateSchema, appApiCustomFieldValueListQuerySchema, appApiCustomFieldValueUpsertSchema, appApiEntityReadQuerySchema, appApiFinanceActionSchema, appApiFinanceDocumentQuerySchema, appApiFinanceExternalLifecycleStateSchema, appApiFinanceExternalReferenceUpsertSchema, appApiFinanceExternalSyncStateSchema, appApiFinancePdfArtifactHeadersSchema, appApiFinanceSettlementObservationSchema, appApiVersionHeader, appApiWebhookReplaySchema, } from "./app-api-contracts.js";
5
5
  import { createAppApiService, withAppApiDeadline, } from "./app-api-service.js";
6
- import { createAppOAuthService } from "./oauth-service.js";
7
6
  import { replayAppWebhookDelivery } from "./webhook-delivery.js";
8
7
  /** Absolute mount prefix for the versioned App API surface. */
9
8
  export const APP_API_BASE_PATH = "/v1/app";
@@ -24,8 +23,8 @@ const definitionUpdateBodySchema = z
24
23
  export function createAppsAppApiRoutes(options = {}) {
25
24
  const routes = new Hono();
26
25
  const service = createAppApiService(options);
27
- const oauth = options.oauth ? createAppOAuthService(options.oauth) : null;
28
26
  const maxPayloadBytes = options.maxPayloadBytes ?? 256 * 1024;
27
+ const maxFinanceArtifactBytes = options.maxFinanceArtifactBytes ?? 10 * 1024 * 1024;
29
28
  routes.use("*", async (c, next) => {
30
29
  if (c.get("callerType") !== "app") {
31
30
  throw new ApiHttpError("App API requires an app access token.", {
@@ -33,12 +32,26 @@ export function createAppsAppApiRoutes(options = {}) {
33
32
  code: "app_api_token_required",
34
33
  });
35
34
  }
36
- const length = Number(c.req.header("content-length") ?? "0");
37
- if (Number.isFinite(length) && length > maxPayloadBytes) {
35
+ const requestLimit = isFinancePdfArtifactRequest(c) ? maxFinanceArtifactBytes : maxPayloadBytes;
36
+ const declaredLength = c.req.header("content-length");
37
+ if (declaredLength !== undefined && !/^(0|[1-9][0-9]*)$/.test(declaredLength)) {
38
+ throw new ApiHttpError("App API Content-Length is invalid.", {
39
+ status: 400,
40
+ code: "app_api_content_length_invalid",
41
+ });
42
+ }
43
+ const length = Number(declaredLength ?? "0");
44
+ if (!Number.isSafeInteger(length)) {
45
+ throw new ApiHttpError("App API Content-Length is invalid.", {
46
+ status: 400,
47
+ code: "app_api_content_length_invalid",
48
+ });
49
+ }
50
+ if (length > requestLimit) {
38
51
  throw new ApiHttpError("App API payload is too large.", {
39
52
  status: 413,
40
53
  code: "app_api_payload_too_large",
41
- details: { maxPayloadBytes },
54
+ details: { maxPayloadBytes: requestLimit },
42
55
  });
43
56
  }
44
57
  return next();
@@ -50,6 +63,67 @@ export function createAppsAppApiRoutes(options = {}) {
50
63
  return run(c, service.listEntities(c.get("db"), appContext(c), entity, query), options.deadlineMs);
51
64
  });
52
65
  routes.get("/finance/documents", (c) => run(c, service.listFinanceDocuments(c.get("db"), appContext(c), parseQuery(c, appApiFinanceDocumentQuerySchema)), options.deadlineMs));
66
+ routes.get("/finance/documents/:id", (c) => {
67
+ const { id } = parseIdParams(c.req.param());
68
+ return run(c, service.getFinanceIssuanceDocument(c.get("db"), appContext(c), id), options.deadlineMs);
69
+ });
70
+ routes.get("/finance/documents/:id/external-reference", (c) => {
71
+ const { id } = parseIdParams(c.req.param());
72
+ return run(c, service.getFinanceExternalReference(c.get("db"), appContext(c), id), options.deadlineMs);
73
+ });
74
+ routes.put("/finance/documents/:id/external-reference", async (c) => {
75
+ const { id } = parseIdParams(c.req.param());
76
+ const body = await parseJsonBody(c, appApiFinanceExternalReferenceUpsertSchema);
77
+ return run(c, service.upsertFinanceExternalReference(c.get("db"), appContext(c), id, body), options.deadlineMs);
78
+ });
79
+ routes.put("/finance/documents/:id/artifacts/provider-pdf", async (c) => {
80
+ const { id } = parseIdParams(c.req.param());
81
+ if (c.req.header("content-type")?.toLowerCase() !== "application/pdf") {
82
+ throw new ApiHttpError("Finance document artifact must be an application/pdf body.", {
83
+ status: 415,
84
+ code: "app_api_finance_artifact_media_type_unsupported",
85
+ });
86
+ }
87
+ const headers = parseArtifactHeaders(c);
88
+ const bytes = await readBoundedArtifactBody(c, maxFinanceArtifactBytes);
89
+ if (!hasPdfSignature(bytes)) {
90
+ throw new ApiHttpError("Finance document artifact is not a PDF.", {
91
+ status: 415,
92
+ code: "app_api_finance_artifact_invalid_pdf",
93
+ });
94
+ }
95
+ const result = await withAppApiDeadline(service.attachFinancePdfArtifact(c.get("db"), appContext(c), id, c.env, headers, bytes), options.deadlineMs);
96
+ return c.json({
97
+ data: {
98
+ outcome: result.data.outcome,
99
+ artifact: {
100
+ id: result.data.artifact.id,
101
+ documentId: result.data.artifact.documentId,
102
+ provider: result.data.artifact.provider,
103
+ fileName: result.data.artifact.fileName,
104
+ byteSize: result.data.artifact.byteSize,
105
+ checksum: result.data.artifact.checksum,
106
+ createdAt: result.data.artifact.createdAt,
107
+ documentUrl: financeArtifactDocumentUrl(c, result.data.artifact.id),
108
+ },
109
+ },
110
+ });
111
+ });
112
+ routes.put("/finance/documents/:id/external-sync-state", async (c) => {
113
+ const { id } = parseIdParams(c.req.param());
114
+ const body = await parseJsonBody(c, appApiFinanceExternalSyncStateSchema);
115
+ return run(c, service.updateFinanceExternalSyncState(c.get("db"), appContext(c), id, body), options.deadlineMs);
116
+ });
117
+ routes.put("/finance/documents/:id/external-lifecycle-state", async (c) => {
118
+ const { id } = parseIdParams(c.req.param());
119
+ const body = await parseJsonBody(c, appApiFinanceExternalLifecycleStateSchema);
120
+ return run(c, service.updateFinanceExternalLifecycleState(c.get("db"), appContext(c), id, body), options.deadlineMs);
121
+ });
122
+ routes.post("/finance/documents/:id/settlement-observations", async (c) => {
123
+ const { id } = parseIdParams(c.req.param());
124
+ const body = await parseJsonBody(c, appApiFinanceSettlementObservationSchema);
125
+ return run(c, service.recordFinanceSettlementObservation(c.get("db"), appContext(c), id, body), options.deadlineMs, 201);
126
+ });
53
127
  routes.post("/finance/actions", async (c) => {
54
128
  const body = await parseJsonBody(c, appApiFinanceActionSchema);
55
129
  return run(c, service.executeFinanceAction(c.get("db"), appContext(c), body), options.deadlineMs);
@@ -81,22 +155,6 @@ export function createAppsAppApiRoutes(options = {}) {
81
155
  }), options.deadlineMs, 202);
82
156
  });
83
157
  routes.get("/audit", (c) => run(c, service.listAuditHistory(c.get("db"), appContext(c), parseQuery(c, appApiAuditQuerySchema)), options.deadlineMs));
84
- routes.post("/oauth/token-exchange", async (c) => {
85
- if (!oauth)
86
- return c.json({ error: "App OAuth is not configured" }, 501);
87
- const context = appContext(c);
88
- await service.requireAccess(c.get("db"), context, ["online-token:exchange"]);
89
- const body = await parseJsonBody(c, appApiTokenExchangeSchema);
90
- return run(c, oauth.token(c.get("db"), {
91
- grantType: "urn:voyant:params:oauth:grant-type:actor-token-exchange",
92
- installationId: context.installationId,
93
- viewerId: body.viewer_id,
94
- viewerScopes: body.viewer_scopes,
95
- contextualScopes: body.contextual_scopes,
96
- clientId: body.client_id,
97
- clientSecret: body.client_secret,
98
- }), options.deadlineMs);
99
- });
100
158
  // Lazy route families forward the original absolute URL, so the loaded
101
159
  // sub-app must expose absolute paths. Mount the relative handlers under the
102
160
  // App API prefix; the matchers in api-runtime install `/v1/app` and
@@ -119,6 +177,7 @@ function appContext(c) {
119
177
  releaseId,
120
178
  tokenMode,
121
179
  viewerId: c.get("appViewerId"),
180
+ contextConstraint: c.get("appContextConstraint"),
122
181
  scopes: c.get("scopes") ?? [],
123
182
  apiVersion: c.req.header(appApiVersionHeader) ?? undefined,
124
183
  };
@@ -135,6 +194,69 @@ function parseEntityParams(input) {
135
194
  throw new RequestValidationError("Invalid route parameters");
136
195
  return parsed.data;
137
196
  }
197
+ function parseArtifactHeaders(c) {
198
+ const parsed = appApiFinancePdfArtifactHeadersSchema.safeParse({
199
+ idempotencyKey: c.req.header("idempotency-key"),
200
+ fileName: c.req.header("x-voyant-artifact-name"),
201
+ });
202
+ if (!parsed.success)
203
+ throw new RequestValidationError("Invalid artifact request headers");
204
+ return parsed.data;
205
+ }
206
+ function isFinancePdfArtifactRequest(c) {
207
+ return (c.req.method === "PUT" &&
208
+ /^\/v1\/app\/finance\/documents\/[^/]+\/artifacts\/provider-pdf$/.test(c.req.path));
209
+ }
210
+ function hasPdfSignature(bytes) {
211
+ return (bytes.byteLength >= 5 &&
212
+ bytes[0] === 0x25 &&
213
+ bytes[1] === 0x50 &&
214
+ bytes[2] === 0x44 &&
215
+ bytes[3] === 0x46 &&
216
+ bytes[4] === 0x2d);
217
+ }
218
+ async function readBoundedArtifactBody(c, maxBytes) {
219
+ const body = c.req.raw.body;
220
+ if (!body)
221
+ return new Uint8Array();
222
+ const reader = body.getReader();
223
+ const chunks = [];
224
+ let total = 0;
225
+ try {
226
+ while (true) {
227
+ const next = await reader.read();
228
+ if (next.done)
229
+ break;
230
+ total += next.value.byteLength;
231
+ if (total > maxBytes) {
232
+ await reader.cancel("Finance document artifact exceeded limit");
233
+ throw new ApiHttpError("Finance document artifact is too large.", {
234
+ status: 413,
235
+ code: "app_api_payload_too_large",
236
+ details: { maxPayloadBytes: maxBytes },
237
+ });
238
+ }
239
+ chunks.push(next.value);
240
+ }
241
+ }
242
+ finally {
243
+ reader.releaseLock();
244
+ }
245
+ const bytes = new Uint8Array(total);
246
+ let offset = 0;
247
+ for (const chunk of chunks) {
248
+ bytes.set(chunk, offset);
249
+ offset += chunk.byteLength;
250
+ }
251
+ return bytes;
252
+ }
253
+ function financeArtifactDocumentUrl(c, artifactId) {
254
+ const path = `/v1/admin/finance/invoice-renditions/${encodeURIComponent(artifactId)}/download`;
255
+ const configuredApiBaseUrl = c.env.API_BASE_URL?.trim().replace(/\/+$/, "");
256
+ return configuredApiBaseUrl
257
+ ? `${configuredApiBaseUrl}${path}`
258
+ : new URL(path, c.req.url).toString();
259
+ }
138
260
  async function run(c, promise, deadlineMs, status = 200) {
139
261
  const data = await withAppApiDeadline(promise, deadlineMs);
140
262
  return c.json(data, status);
@@ -1,22 +1,25 @@
1
+ import type { VoyantAppContextConstraint } from "@voyant-travel/core";
1
2
  import type { CustomFieldValueLifecycleRuntime, CustomFieldValueOperationsRuntime } from "@voyant-travel/core/runtime-port";
2
3
  import { type CustomFieldDefinitionOwner, createCustomFieldsService } from "@voyant-travel/custom-fields";
4
+ import { type FinanceAppApiRuntime } from "@voyant-travel/finance-contracts/app-api";
3
5
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
4
- import type { AppApiAuditQuery, AppApiEntityReadQuery, AppApiFinanceActionInput, AppApiFinanceDocumentQuery } from "./app-api-contracts.js";
6
+ import type { AppApiAuditQuery, AppApiEntityReadQuery, AppApiFinanceActionInput, AppApiFinanceDocumentQuery, AppApiFinanceExternalLifecycleStateInput, AppApiFinanceExternalReferenceUpsertInput, AppApiFinanceExternalSyncStateInput, AppApiFinancePdfArtifactHeaders, AppApiFinanceSettlementObservationInput } from "./app-api-contracts.js";
5
7
  export interface AppApiAccessContext {
6
8
  appId: string;
7
9
  installationId: string;
8
10
  releaseId: string;
9
11
  tokenMode: "offline" | "online";
10
12
  viewerId?: string;
13
+ contextConstraint?: VoyantAppContextConstraint;
11
14
  scopes: readonly string[];
12
15
  apiVersion?: string;
13
16
  }
14
17
  export interface AppApiEntityReader {
15
18
  list(db: PostgresJsDatabase, query: AppApiEntityReadQuery): Promise<unknown>;
16
19
  }
17
- export interface AppApiFinanceRuntime {
18
- listDocuments(db: PostgresJsDatabase, query: AppApiFinanceDocumentQuery): Promise<unknown>;
19
- executeAction(db: PostgresJsDatabase, input: AppApiFinanceActionInput): Promise<unknown>;
20
+ export interface AppApiFinanceRuntime extends Partial<FinanceAppApiRuntime> {
21
+ listDocuments?(db: PostgresJsDatabase, query: AppApiFinanceDocumentQuery): Promise<unknown>;
22
+ executeAction?(db: PostgresJsDatabase, input: AppApiFinanceActionInput): Promise<unknown>;
20
23
  }
21
24
  export interface AppApiRateLimitPolicy {
22
25
  installationLimit: number;
@@ -63,9 +66,51 @@ export declare function createAppApiService(options?: AppApiServiceOptions): {
63
66
  listFinanceDocuments: (db: PostgresJsDatabase, context: AppApiAccessContext, query: AppApiFinanceDocumentQuery) => Promise<{
64
67
  data: unknown;
65
68
  }>;
69
+ getFinanceIssuanceDocument: (db: PostgresJsDatabase, context: AppApiAccessContext, documentId: string) => Promise<{
70
+ data: import("@voyant-travel/finance-contracts/app-api").FinanceAppApiIssuanceDocument;
71
+ }>;
66
72
  executeFinanceAction: (db: PostgresJsDatabase, context: AppApiAccessContext, input: AppApiFinanceActionInput) => Promise<{
67
73
  data: unknown;
68
74
  }>;
75
+ getFinanceExternalReference: (db: PostgresJsDatabase, context: AppApiAccessContext, documentId: string) => Promise<{
76
+ data: import("@voyant-travel/finance-contracts/app-api").FinanceAppApiExternalReference;
77
+ }>;
78
+ upsertFinanceExternalReference: (db: PostgresJsDatabase, context: AppApiAccessContext, documentId: string, input: AppApiFinanceExternalReferenceUpsertInput) => Promise<{
79
+ data: {
80
+ status: "ok";
81
+ reference: import("@voyant-travel/finance-contracts/app-api").FinanceAppApiExternalReference;
82
+ referenceOutcome: "created" | "updated" | "unchanged";
83
+ allocationOutcome: "not_requested" | "applied" | "already_applied";
84
+ };
85
+ }>;
86
+ attachFinancePdfArtifact: (db: PostgresJsDatabase, context: AppApiAccessContext, documentId: string, environment: unknown, headers: AppApiFinancePdfArtifactHeaders, bytes: Uint8Array) => Promise<{
87
+ data: {
88
+ status: "ok";
89
+ outcome: "created" | "unchanged";
90
+ artifact: import("@voyant-travel/finance-contracts/app-api").FinanceAppApiPdfArtifact;
91
+ };
92
+ }>;
93
+ updateFinanceExternalSyncState: (db: PostgresJsDatabase, context: AppApiAccessContext, documentId: string, input: AppApiFinanceExternalSyncStateInput) => Promise<{
94
+ data: {
95
+ status: "ok";
96
+ outcome: "created" | "updated" | "unchanged";
97
+ sync: import("@voyant-travel/finance-contracts/app-api").FinanceAppApiExternalSyncState;
98
+ };
99
+ }>;
100
+ updateFinanceExternalLifecycleState: (db: PostgresJsDatabase, context: AppApiAccessContext, documentId: string, input: AppApiFinanceExternalLifecycleStateInput) => Promise<{
101
+ data: {
102
+ status: "ok";
103
+ outcome: "created" | "unchanged";
104
+ lifecycle: import("@voyant-travel/finance-contracts/app-api").FinanceAppApiExternalLifecycleObservation;
105
+ };
106
+ }>;
107
+ recordFinanceSettlementObservation: (db: PostgresJsDatabase, context: AppApiAccessContext, documentId: string, input: AppApiFinanceSettlementObservationInput) => Promise<{
108
+ data: {
109
+ status: "ok";
110
+ outcome: "created" | "unchanged";
111
+ observation: import("@voyant-travel/finance-contracts/app-api").FinanceAppApiSettlementObservation;
112
+ };
113
+ }>;
69
114
  listCustomFieldDefinitions: (db: PostgresJsDatabase, context: AppApiAccessContext, query: Parameters<NonNullable<{
70
115
  list(db: PostgresJsDatabase, query: import("@voyant-travel/custom-fields").CustomFieldDefinitionListQuery): Promise<{
71
116
  data: {
@@ -1,8 +1,10 @@
1
1
  import { createAppCustomFieldDefinitionOwner, createCustomFieldsService, } from "@voyant-travel/custom-fields";
2
+ import { FinanceAppApiNumberConflictError, } from "@voyant-travel/finance-contracts/app-api";
2
3
  import { ApiHttpError } from "@voyant-travel/hono";
3
4
  import { and, asc, eq, sql } from "drizzle-orm";
4
5
  import { assertActiveAppInstallationAccess } from "./access-boundary.js";
5
6
  import { APP_API_VERSION } from "./app-api-contracts.js";
7
+ import { audit } from "./installation-reconciliation.js";
6
8
  import { appAuditEvents, appGrants, appReleases, appWebhookSubscriptions } from "./schema.js";
7
9
  const DEFAULT_RATE_LIMIT = { installationLimit: 120, appLimit: 600, windowMs: 60_000 };
8
10
  const REMOTE_APP_NAMESPACE = /^\$app(?::[a-z][a-z0-9-]*)?$/;
@@ -49,15 +51,32 @@ export function createAppApiService(options = {}) {
49
51
  return { data: await reader.list(db, query) };
50
52
  }
51
53
  async function listFinanceDocuments(db, context, query) {
54
+ assertUnconstrainedFinanceContext(context);
52
55
  await requireAccess(db, context, ["finance-documents:read"]);
53
56
  if (!options.finance)
54
57
  throw notSupported("Finance App API runtime is not configured.");
58
+ if (!options.finance.listDocuments)
59
+ throw notSupported("Finance document listing is unavailable.");
55
60
  return { data: await options.finance.listDocuments(db, query) };
56
61
  }
62
+ async function getFinanceIssuanceDocument(db, context, documentId) {
63
+ assertFinanceDocumentContext(context, documentId);
64
+ await requireAccess(db, context, ["finance-documents:read"]);
65
+ const getDocument = options.finance?.getIssuanceDocument;
66
+ if (!getDocument)
67
+ throw notSupported("Finance document hydration is unavailable.");
68
+ const data = await getDocument(db, documentId);
69
+ if (!data)
70
+ throw notFound("Finance document not found.");
71
+ return { data };
72
+ }
57
73
  async function executeFinanceAction(db, context, input) {
74
+ assertFinanceDocumentContext(context, input.invoiceId);
58
75
  await requireAccess(db, context, [`finance-actions:${input.action}`]);
59
76
  if (!options.finance)
60
77
  throw notSupported("Finance App API runtime is not configured.");
78
+ if (!options.finance.executeAction)
79
+ throw notSupported("Finance actions are unavailable.");
61
80
  if (!input.approvalId) {
62
81
  throw new ApiHttpError("Finance action requires action-ledger approval.", {
63
82
  status: 403,
@@ -66,6 +85,198 @@ export function createAppApiService(options = {}) {
66
85
  }
67
86
  return { data: await options.finance.executeAction(db, input) };
68
87
  }
88
+ async function getFinanceExternalReference(db, context, documentId) {
89
+ assertFinanceDocumentContext(context, documentId);
90
+ await requireAccess(db, context, ["finance-external-references:read"]);
91
+ const getReference = options.finance?.getExternalReference;
92
+ if (!getReference)
93
+ throw notSupported("Finance external references are unavailable.");
94
+ const data = await getReference(db, documentId, context.appId);
95
+ if (!data)
96
+ throw notFound("Finance external reference not found.");
97
+ return { data };
98
+ }
99
+ async function upsertFinanceExternalReference(db, context, documentId, input) {
100
+ assertFinanceDocumentContext(context, documentId);
101
+ const scopes = ["finance-external-references:write"];
102
+ if (input.allocation)
103
+ scopes.push("finance-external-allocation:write");
104
+ const access = await requireAccess(db, context, scopes);
105
+ const upsertReference = options.finance?.upsertExternalReference;
106
+ if (!upsertReference)
107
+ throw notSupported("Finance external reference writes are unavailable.");
108
+ let result;
109
+ try {
110
+ result = await db.transaction(async (tx) => {
111
+ const mutation = await upsertReference(tx, documentId, context.appId, input);
112
+ if (mutation.status === "ok") {
113
+ await audit(tx, access.installation, `app:${context.appId}`, "reconciliation", "finance.external-reference.upserted", {
114
+ documentId,
115
+ provider: context.appId,
116
+ referenceOutcome: mutation.referenceOutcome,
117
+ allocationOutcome: mutation.allocationOutcome,
118
+ releaseId: context.releaseId,
119
+ tokenMode: context.tokenMode,
120
+ });
121
+ }
122
+ return mutation;
123
+ });
124
+ }
125
+ catch (error) {
126
+ if (error instanceof FinanceAppApiNumberConflictError) {
127
+ throw new ApiHttpError("Finance document number is already in use.", {
128
+ status: 409,
129
+ code: "app_api_finance_number_conflict",
130
+ details: { invoiceNumber: error.invoiceNumber },
131
+ });
132
+ }
133
+ throw error;
134
+ }
135
+ if (result.status === "not_found")
136
+ throw notFound("Finance document not found.");
137
+ if (result.status === "allocation_conflict") {
138
+ throw new ApiHttpError("Finance document has already received a different allocation.", {
139
+ status: 409,
140
+ code: "app_api_finance_allocation_conflict",
141
+ details: result,
142
+ });
143
+ }
144
+ return { data: result };
145
+ }
146
+ async function attachFinancePdfArtifact(db, context, documentId, environment, headers, bytes) {
147
+ assertFinanceDocumentContext(context, documentId);
148
+ const access = await requireAccess(db, context, ["finance-document-artifacts:write"]);
149
+ const attachArtifact = options.finance?.attachPdfArtifact;
150
+ if (!attachArtifact)
151
+ throw notSupported("Finance document artifact writes are unavailable.");
152
+ const result = await attachArtifact(db, environment, documentId, context.appId, {
153
+ bytes,
154
+ contentType: "application/pdf",
155
+ fileName: headers.fileName,
156
+ idempotencyKey: headers.idempotencyKey,
157
+ });
158
+ if (result.status === "not_found")
159
+ throw notFound("Finance document not found.");
160
+ if (result.status === "not_configured") {
161
+ throw new ApiHttpError("Finance document storage is unavailable.", {
162
+ status: 503,
163
+ code: "app_api_finance_artifact_storage_unavailable",
164
+ });
165
+ }
166
+ if (result.status === "conflict") {
167
+ throw new ApiHttpError("Artifact idempotency key was reused with different content.", {
168
+ status: 409,
169
+ code: "app_api_finance_artifact_idempotency_conflict",
170
+ });
171
+ }
172
+ await audit(db, access.installation, `app:${context.appId}`, "reconciliation", "finance.document-artifact.attached", {
173
+ documentId,
174
+ provider: context.appId,
175
+ artifactId: result.artifact.id,
176
+ outcome: result.outcome,
177
+ releaseId: context.releaseId,
178
+ tokenMode: context.tokenMode,
179
+ });
180
+ return { data: result };
181
+ }
182
+ async function updateFinanceExternalSyncState(db, context, documentId, input) {
183
+ assertFinanceDocumentContext(context, documentId);
184
+ const access = await requireAccess(db, context, ["finance-external-sync:write"]);
185
+ const updateSyncState = options.finance?.updateExternalSyncState;
186
+ if (!updateSyncState)
187
+ throw notSupported("Finance external sync writes are unavailable.");
188
+ const result = await db.transaction(async (tx) => {
189
+ const mutation = await updateSyncState(tx, documentId, context.appId, input);
190
+ if (mutation.status === "ok") {
191
+ await audit(tx, access.installation, `app:${context.appId}`, "reconciliation", "finance.external-sync.updated", {
192
+ documentId,
193
+ provider: context.appId,
194
+ operationId: input.operationId,
195
+ syncStatus: input.status,
196
+ outcome: mutation.outcome,
197
+ releaseId: context.releaseId,
198
+ tokenMode: context.tokenMode,
199
+ });
200
+ }
201
+ return mutation;
202
+ });
203
+ if (result.status === "not_found")
204
+ throw notFound("Finance document not found.");
205
+ if (result.status === "conflict") {
206
+ throw new ApiHttpError("External sync state conflicts with the current observation.", {
207
+ status: 409,
208
+ code: `app_api_finance_external_sync_${result.reason}`,
209
+ details: result,
210
+ });
211
+ }
212
+ return { data: result };
213
+ }
214
+ async function updateFinanceExternalLifecycleState(db, context, documentId, input) {
215
+ assertFinanceDocumentContext(context, documentId);
216
+ const access = await requireAccess(db, context, ["finance-external-lifecycle:write"]);
217
+ const updateLifecycleState = options.finance?.updateExternalLifecycleState;
218
+ if (!updateLifecycleState) {
219
+ throw notSupported("Finance external lifecycle writes are unavailable.");
220
+ }
221
+ const result = await db.transaction(async (tx) => {
222
+ const mutation = await updateLifecycleState(tx, documentId, context.appId, input);
223
+ if (mutation.status === "ok") {
224
+ await audit(tx, access.installation, `app:${context.appId}`, "reconciliation", "finance.external-lifecycle.updated", {
225
+ documentId,
226
+ provider: context.appId,
227
+ operationId: input.operationId,
228
+ lifecycleState: input.state,
229
+ outcome: mutation.outcome,
230
+ releaseId: context.releaseId,
231
+ tokenMode: context.tokenMode,
232
+ });
233
+ }
234
+ return mutation;
235
+ });
236
+ if (result.status === "not_found")
237
+ throw notFound("Finance document not found.");
238
+ if (result.status === "conflict") {
239
+ throw new ApiHttpError("External lifecycle state conflicts with the current document.", {
240
+ status: 409,
241
+ code: `app_api_finance_external_lifecycle_${result.reason}`,
242
+ details: result,
243
+ });
244
+ }
245
+ return { data: result };
246
+ }
247
+ async function recordFinanceSettlementObservation(db, context, documentId, input) {
248
+ assertFinanceDocumentContext(context, documentId);
249
+ const access = await requireAccess(db, context, ["finance-settlement-observations:write"]);
250
+ const recordObservation = options.finance?.recordSettlementObservation;
251
+ if (!recordObservation) {
252
+ throw notSupported("Finance settlement observations are unavailable.");
253
+ }
254
+ const result = await db.transaction(async (tx) => {
255
+ const mutation = await recordObservation(tx, documentId, context.appId, input);
256
+ if (mutation.status === "ok") {
257
+ await audit(tx, access.installation, `app:${context.appId}`, "reconciliation", "finance.settlement-observation.recorded", {
258
+ documentId,
259
+ provider: context.appId,
260
+ operationId: input.operationId,
261
+ settlementStatus: input.status,
262
+ outcome: mutation.outcome,
263
+ releaseId: context.releaseId,
264
+ tokenMode: context.tokenMode,
265
+ });
266
+ }
267
+ return mutation;
268
+ });
269
+ if (result.status === "not_found")
270
+ throw notFound("Finance document not found.");
271
+ if (result.status === "conflict") {
272
+ throw new ApiHttpError("Settlement observation conflicts with the current document.", {
273
+ status: 409,
274
+ code: `app_api_finance_settlement_observation_${result.reason}`,
275
+ details: result,
276
+ });
277
+ }
278
+ return { data: result };
279
+ }
69
280
  async function listCustomFieldDefinitions(db, context, query) {
70
281
  const access = await requireAccess(db, context, ["custom-field-definitions:read"]);
71
282
  return customFieldsRequired().listForOwner(db, owner(access), query);
@@ -120,7 +331,14 @@ export function createAppApiService(options = {}) {
120
331
  introspect,
121
332
  listEntities,
122
333
  listFinanceDocuments,
334
+ getFinanceIssuanceDocument,
123
335
  executeFinanceAction,
336
+ getFinanceExternalReference,
337
+ upsertFinanceExternalReference,
338
+ attachFinancePdfArtifact,
339
+ updateFinanceExternalSyncState,
340
+ updateFinanceExternalLifecycleState,
341
+ recordFinanceSettlementObservation,
124
342
  listCustomFieldDefinitions,
125
343
  createCustomFieldDefinition,
126
344
  updateCustomFieldDefinition,
@@ -218,6 +436,25 @@ export function assertCompatibleVersion(requested, range) {
218
436
  });
219
437
  }
220
438
  }
439
+ function assertFinanceDocumentContext(context, documentId) {
440
+ const constraint = context.contextConstraint;
441
+ if (!constraint)
442
+ return;
443
+ if (constraint.entity?.type !== "invoice" || !documentId || constraint.entity.id !== documentId) {
444
+ throw new ApiHttpError("Online app token is not bound to this finance document.", {
445
+ status: 403,
446
+ code: "app_api_entity_context_mismatch",
447
+ });
448
+ }
449
+ }
450
+ function assertUnconstrainedFinanceContext(context) {
451
+ if (!context.contextConstraint)
452
+ return;
453
+ throw new ApiHttpError("Entity-bound online app tokens cannot list finance documents.", {
454
+ status: 403,
455
+ code: "app_api_entity_context_mismatch",
456
+ });
457
+ }
221
458
  export async function withAppApiDeadline(promise, timeoutMs = 5_000) {
222
459
  let timeout;
223
460
  try {
@@ -1,5 +1,7 @@
1
+ import { FinanceAppApiNumberConflictError } from "@voyant-travel/finance-contracts/app-api";
1
2
  import { ApiHttpError } from "@voyant-travel/hono";
2
3
  import { describe, expect, it, vi } from "vitest";
4
+ import { appApiFinanceExternalReferenceUpsertSchema } from "./app-api-contracts.js";
3
5
  import { assertAppNamespaceAlias, assertCompatibleVersion, createAppApiService, } from "./app-api-service.js";
4
6
  import { appGrants, appInstallations, appReleases } from "./schema.js";
5
7
  function postgresStub(implementation) {
@@ -86,6 +88,42 @@ describe("App API service boundary", () => {
86
88
  })).rejects.toMatchObject({ status: 403, code: "app_api_finance_approval_required" });
87
89
  expect(executeAction).not.toHaveBeenCalled();
88
90
  });
91
+ it("hydrates one finance document through the stable App API identifier", async () => {
92
+ const getIssuanceDocument = vi.fn().mockResolvedValue({ id: "inv_1" });
93
+ const service = createAppApiService({
94
+ finance: { listDocuments: vi.fn(), executeAction: vi.fn(), getIssuanceDocument },
95
+ });
96
+ await expect(service.getFinanceIssuanceDocument(createAccessDb({ scopes: ["finance-documents:read"] }), { ...context, scopes: ["finance-documents:read"] }, "inv_1")).resolves.toEqual({ data: { id: "inv_1" } });
97
+ expect(getIssuanceDocument).toHaveBeenCalledWith(expect.anything(), "inv_1");
98
+ });
99
+ it("maps a provider-owned invoice number conflict to a stable App API conflict", async () => {
100
+ const upsertExternalReference = vi
101
+ .fn()
102
+ .mockRejectedValue(new FinanceAppApiNumberConflictError("SB-42"));
103
+ const service = createAppApiService({ finance: { upsertExternalReference } });
104
+ const db = createAccessDb({
105
+ scopes: ["finance-external-references:write", "finance-external-allocation:write"],
106
+ });
107
+ Object.assign(db, {
108
+ transaction: (callback) => callback(db),
109
+ });
110
+ await expect(service.upsertFinanceExternalReference(db, {
111
+ ...context,
112
+ scopes: ["finance-external-references:write", "finance-external-allocation:write"],
113
+ }, "inv_1", {
114
+ reference: {
115
+ externalId: null,
116
+ externalNumber: null,
117
+ externalUrl: null,
118
+ status: null,
119
+ metadata: null,
120
+ syncedAt: null,
121
+ syncError: null,
122
+ },
123
+ allocation: { invoiceNumber: "SB-42" },
124
+ })).rejects.toMatchObject({ status: 409, code: "app_api_finance_number_conflict" });
125
+ expect(upsertExternalReference).toHaveBeenCalledWith(expect.anything(), "inv_1", "app_1", expect.anything());
126
+ });
89
127
  it("isolates rate limits per installation", async () => {
90
128
  const service = createAppApiService({
91
129
  rateLimit: { installationLimit: 1, appLimit: 10, windowMs: 60_000 },
@@ -106,4 +144,18 @@ describe("App API service boundary", () => {
106
144
  expect(() => assertCompatibleVersion("2026-07-01", { min: "2026-07-01", max: "2026-12-31" })).not.toThrow();
107
145
  expect(() => assertCompatibleVersion("2027-01-01", { min: "2026-07-01", max: "2026-12-31" })).toThrow(ApiHttpError);
108
146
  });
147
+ it("requires a complete bounded external-reference replacement", () => {
148
+ expect(appApiFinanceExternalReferenceUpsertSchema.safeParse({ reference: {} }).success).toBe(false);
149
+ expect(appApiFinanceExternalReferenceUpsertSchema.safeParse({
150
+ reference: {
151
+ externalId: null,
152
+ externalNumber: null,
153
+ externalUrl: null,
154
+ status: null,
155
+ metadata: { payload: "x".repeat(17_000) },
156
+ syncedAt: null,
157
+ syncError: null,
158
+ },
159
+ }).success).toBe(false);
160
+ });
109
161
  });
package/dist/consent.d.ts CHANGED
@@ -13,3 +13,4 @@ export interface ComputedConsent {
13
13
  deniedOptionalScopes: string[];
14
14
  }
15
15
  export declare function computeAppConsent(input: ConsentComputationInput): ComputedConsent;
16
+ export declare function grantableRemoteAppScopes(catalog: AccessCatalog): Set<string>;