@voyant-travel/apps 0.7.0 → 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 (38) hide show
  1. package/dist/api-runtime.d.ts +23 -11
  2. package/dist/api-runtime.js +38 -1
  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 +58 -8
  6. package/dist/app-api-contracts.js +102 -9
  7. package/dist/app-api-finance-routes.test.js +482 -2
  8. package/dist/app-api-routes.d.ts +6 -5
  9. package/dist/app-api-routes.js +131 -22
  10. package/dist/app-api-service.d.ts +31 -1
  11. package/dist/app-api-service.js +162 -0
  12. package/dist/consent.d.ts +1 -0
  13. package/dist/consent.js +2 -2
  14. package/dist/contracts.d.ts +1 -25
  15. package/dist/contracts.js +3 -10
  16. package/dist/oauth-service.d.ts +6 -1
  17. package/dist/oauth-service.js +41 -4
  18. package/dist/oauth-service.test.js +39 -2
  19. package/dist/routes-openapi.d.ts +1 -25
  20. package/dist/routes-viewer-scopes.test.d.ts +1 -0
  21. package/dist/routes-viewer-scopes.test.js +46 -0
  22. package/dist/routes.d.ts +5 -5
  23. package/dist/routes.js +41 -24
  24. package/dist/runtime-contributor.d.ts +9 -1
  25. package/dist/runtime-contributor.js +25 -2
  26. package/dist/runtime-contributor.test.d.ts +1 -0
  27. package/dist/runtime-contributor.test.js +52 -0
  28. package/dist/runtime-port.d.ts +8 -0
  29. package/dist/runtime-port.js +22 -0
  30. package/dist/session-token-service.d.ts +2 -0
  31. package/dist/session-token-service.js +44 -27
  32. package/dist/session-token-service.test.d.ts +1 -0
  33. package/dist/session-token-service.test.js +157 -0
  34. package/dist/session-token.d.ts +5 -1
  35. package/dist/session-token.js +0 -0
  36. package/dist/session-token.test.js +5 -0
  37. package/dist/voyant.js +95 -1
  38. package/package.json +8 -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, appApiFinanceExternalReferenceUpsertSchema, 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();
@@ -63,6 +76,54 @@ export function createAppsAppApiRoutes(options = {}) {
63
76
  const body = await parseJsonBody(c, appApiFinanceExternalReferenceUpsertSchema);
64
77
  return run(c, service.upsertFinanceExternalReference(c.get("db"), appContext(c), id, body), options.deadlineMs);
65
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
+ });
66
127
  routes.post("/finance/actions", async (c) => {
67
128
  const body = await parseJsonBody(c, appApiFinanceActionSchema);
68
129
  return run(c, service.executeFinanceAction(c.get("db"), appContext(c), body), options.deadlineMs);
@@ -94,22 +155,6 @@ export function createAppsAppApiRoutes(options = {}) {
94
155
  }), options.deadlineMs, 202);
95
156
  });
96
157
  routes.get("/audit", (c) => run(c, service.listAuditHistory(c.get("db"), appContext(c), parseQuery(c, appApiAuditQuerySchema)), options.deadlineMs));
97
- routes.post("/oauth/token-exchange", async (c) => {
98
- if (!oauth)
99
- return c.json({ error: "App OAuth is not configured" }, 501);
100
- const context = appContext(c);
101
- await service.requireAccess(c.get("db"), context, ["online-token:exchange"]);
102
- const body = await parseJsonBody(c, appApiTokenExchangeSchema);
103
- return run(c, oauth.token(c.get("db"), {
104
- grantType: "urn:voyant:params:oauth:grant-type:actor-token-exchange",
105
- installationId: context.installationId,
106
- viewerId: body.viewer_id,
107
- viewerScopes: body.viewer_scopes,
108
- contextualScopes: body.contextual_scopes,
109
- clientId: body.client_id,
110
- clientSecret: body.client_secret,
111
- }), options.deadlineMs);
112
- });
113
158
  // Lazy route families forward the original absolute URL, so the loaded
114
159
  // sub-app must expose absolute paths. Mount the relative handlers under the
115
160
  // App API prefix; the matchers in api-runtime install `/v1/app` and
@@ -132,6 +177,7 @@ function appContext(c) {
132
177
  releaseId,
133
178
  tokenMode,
134
179
  viewerId: c.get("appViewerId"),
180
+ contextConstraint: c.get("appContextConstraint"),
135
181
  scopes: c.get("scopes") ?? [],
136
182
  apiVersion: c.req.header(appApiVersionHeader) ?? undefined,
137
183
  };
@@ -148,6 +194,69 @@ function parseEntityParams(input) {
148
194
  throw new RequestValidationError("Invalid route parameters");
149
195
  return parsed.data;
150
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
+ }
151
260
  async function run(c, promise, deadlineMs, status = 200) {
152
261
  const data = await withAppApiDeadline(promise, deadlineMs);
153
262
  return c.json(data, status);
@@ -1,14 +1,16 @@
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";
3
4
  import { type FinanceAppApiRuntime } from "@voyant-travel/finance-contracts/app-api";
4
5
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
5
- import type { AppApiAuditQuery, AppApiEntityReadQuery, AppApiFinanceActionInput, AppApiFinanceDocumentQuery, AppApiFinanceExternalReferenceUpsertInput } from "./app-api-contracts.js";
6
+ import type { AppApiAuditQuery, AppApiEntityReadQuery, AppApiFinanceActionInput, AppApiFinanceDocumentQuery, AppApiFinanceExternalLifecycleStateInput, AppApiFinanceExternalReferenceUpsertInput, AppApiFinanceExternalSyncStateInput, AppApiFinancePdfArtifactHeaders, AppApiFinanceSettlementObservationInput } from "./app-api-contracts.js";
6
7
  export interface AppApiAccessContext {
7
8
  appId: string;
8
9
  installationId: string;
9
10
  releaseId: string;
10
11
  tokenMode: "offline" | "online";
11
12
  viewerId?: string;
13
+ contextConstraint?: VoyantAppContextConstraint;
12
14
  scopes: readonly string[];
13
15
  apiVersion?: string;
14
16
  }
@@ -81,6 +83,34 @@ export declare function createAppApiService(options?: AppApiServiceOptions): {
81
83
  allocationOutcome: "not_requested" | "applied" | "already_applied";
82
84
  };
83
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
+ }>;
84
114
  listCustomFieldDefinitions: (db: PostgresJsDatabase, context: AppApiAccessContext, query: Parameters<NonNullable<{
85
115
  list(db: PostgresJsDatabase, query: import("@voyant-travel/custom-fields").CustomFieldDefinitionListQuery): Promise<{
86
116
  data: {
@@ -51,6 +51,7 @@ export function createAppApiService(options = {}) {
51
51
  return { data: await reader.list(db, query) };
52
52
  }
53
53
  async function listFinanceDocuments(db, context, query) {
54
+ assertUnconstrainedFinanceContext(context);
54
55
  await requireAccess(db, context, ["finance-documents:read"]);
55
56
  if (!options.finance)
56
57
  throw notSupported("Finance App API runtime is not configured.");
@@ -59,6 +60,7 @@ export function createAppApiService(options = {}) {
59
60
  return { data: await options.finance.listDocuments(db, query) };
60
61
  }
61
62
  async function getFinanceIssuanceDocument(db, context, documentId) {
63
+ assertFinanceDocumentContext(context, documentId);
62
64
  await requireAccess(db, context, ["finance-documents:read"]);
63
65
  const getDocument = options.finance?.getIssuanceDocument;
64
66
  if (!getDocument)
@@ -69,6 +71,7 @@ export function createAppApiService(options = {}) {
69
71
  return { data };
70
72
  }
71
73
  async function executeFinanceAction(db, context, input) {
74
+ assertFinanceDocumentContext(context, input.invoiceId);
72
75
  await requireAccess(db, context, [`finance-actions:${input.action}`]);
73
76
  if (!options.finance)
74
77
  throw notSupported("Finance App API runtime is not configured.");
@@ -83,6 +86,7 @@ export function createAppApiService(options = {}) {
83
86
  return { data: await options.finance.executeAction(db, input) };
84
87
  }
85
88
  async function getFinanceExternalReference(db, context, documentId) {
89
+ assertFinanceDocumentContext(context, documentId);
86
90
  await requireAccess(db, context, ["finance-external-references:read"]);
87
91
  const getReference = options.finance?.getExternalReference;
88
92
  if (!getReference)
@@ -93,6 +97,7 @@ export function createAppApiService(options = {}) {
93
97
  return { data };
94
98
  }
95
99
  async function upsertFinanceExternalReference(db, context, documentId, input) {
100
+ assertFinanceDocumentContext(context, documentId);
96
101
  const scopes = ["finance-external-references:write"];
97
102
  if (input.allocation)
98
103
  scopes.push("finance-external-allocation:write");
@@ -138,6 +143,140 @@ export function createAppApiService(options = {}) {
138
143
  }
139
144
  return { data: result };
140
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
+ }
141
280
  async function listCustomFieldDefinitions(db, context, query) {
142
281
  const access = await requireAccess(db, context, ["custom-field-definitions:read"]);
143
282
  return customFieldsRequired().listForOwner(db, owner(access), query);
@@ -196,6 +335,10 @@ export function createAppApiService(options = {}) {
196
335
  executeFinanceAction,
197
336
  getFinanceExternalReference,
198
337
  upsertFinanceExternalReference,
338
+ attachFinancePdfArtifact,
339
+ updateFinanceExternalSyncState,
340
+ updateFinanceExternalLifecycleState,
341
+ recordFinanceSettlementObservation,
199
342
  listCustomFieldDefinitions,
200
343
  createCustomFieldDefinition,
201
344
  updateCustomFieldDefinition,
@@ -293,6 +436,25 @@ export function assertCompatibleVersion(requested, range) {
293
436
  });
294
437
  }
295
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
+ }
296
458
  export async function withAppApiDeadline(promise, timeoutMs = 5_000) {
297
459
  let timeout;
298
460
  try {
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>;
package/dist/consent.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ApiHttpError } from "@voyant-travel/hono";
2
2
  export function computeAppConsent(input) {
3
3
  const normalized = parseReleaseScopes(input.release.normalizedRecord);
4
- const remoteSafe = remoteSafeScopes(input.accessCatalog);
4
+ const remoteSafe = grantableRemoteAppScopes(input.accessCatalog);
5
5
  const operatorGranted = new Set(input.operatorGrantedScopes);
6
6
  const optionalGrantRequest = new Set(input.grantedOptionalScopes ?? []);
7
7
  const requiredScopes = normalized.requestedScopes.filter((scope) => canGrantScope(scope, remoteSafe, operatorGranted));
@@ -27,7 +27,7 @@ export function computeAppConsent(input) {
27
27
  function canGrantScope(scope, remoteSafe, operatorGranted) {
28
28
  return remoteSafe.has(scope) && operatorGranted.has(scope);
29
29
  }
30
- function remoteSafeScopes(catalog) {
30
+ export function grantableRemoteAppScopes(catalog) {
31
31
  const catalogScopes = new Set(catalog.resources.flatMap((resource) => resource.actions.map((action) => `${resource.resource}:${action.action}`)));
32
32
  const remoteSafe = new Set();
33
33
  // A scope is remote-safe when its owning resource is flagged remoteSafe, or
@@ -222,7 +222,7 @@ export declare const appOAuthAuthorizeQuerySchema: z.ZodObject<{
222
222
  state: z.ZodString;
223
223
  code_challenge: z.ZodString;
224
224
  code_challenge_method: z.ZodLiteral<"S256">;
225
- actor_id: z.ZodString;
225
+ actor_id: z.ZodOptional<z.ZodString>;
226
226
  operator_scopes: z.ZodDefault<z.ZodString>;
227
227
  optional_scopes: z.ZodDefault<z.ZodString>;
228
228
  }, z.core.$strict>;
@@ -238,14 +238,6 @@ export declare const appOAuthTokenSchema: z.ZodPipe<z.ZodDiscriminatedUnion<[z.Z
238
238
  refresh_token: z.ZodString;
239
239
  client_id: z.ZodString;
240
240
  client_secret: z.ZodOptional<z.ZodString>;
241
- }, z.core.$strip>, z.ZodObject<{
242
- grant_type: z.ZodLiteral<"urn:voyant:params:oauth:grant-type:actor-token-exchange">;
243
- installation_id: z.ZodString;
244
- viewer_id: z.ZodString;
245
- viewer_scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
246
- contextual_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
247
- client_id: z.ZodString;
248
- client_secret: z.ZodOptional<z.ZodString>;
249
241
  }, z.core.$strip>], "grant_type">, z.ZodTransform<{
250
242
  client_secret: string | undefined;
251
243
  grant_type: "authorization_code";
@@ -258,14 +250,6 @@ export declare const appOAuthTokenSchema: z.ZodPipe<z.ZodDiscriminatedUnion<[z.Z
258
250
  grant_type: "refresh_token";
259
251
  refresh_token: string;
260
252
  client_id: string;
261
- } | {
262
- client_secret: string | undefined;
263
- grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
264
- installation_id: string;
265
- viewer_id: string;
266
- viewer_scopes: string[];
267
- client_id: string;
268
- contextual_scopes?: string[] | undefined;
269
253
  }, {
270
254
  grant_type: "authorization_code";
271
255
  code: string;
@@ -278,14 +262,6 @@ export declare const appOAuthTokenSchema: z.ZodPipe<z.ZodDiscriminatedUnion<[z.Z
278
262
  refresh_token: string;
279
263
  client_id: string;
280
264
  client_secret?: string | undefined;
281
- } | {
282
- grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
283
- installation_id: string;
284
- viewer_id: string;
285
- viewer_scopes: string[];
286
- client_id: string;
287
- contextual_scopes?: string[] | undefined;
288
- client_secret?: string | undefined;
289
265
  }>>;
290
266
  export declare const appCredentialRevocationSchema: z.ZodObject<{
291
267
  installationId: z.ZodString;
package/dist/contracts.js CHANGED
@@ -271,7 +271,9 @@ export const appOAuthAuthorizeQuerySchema = z
271
271
  state: z.string().trim().min(1),
272
272
  code_challenge: z.string().trim().min(1),
273
273
  code_challenge_method: z.literal("S256"),
274
- actor_id: z.string().trim().min(1),
274
+ /** Deprecated input; the route derives the actor from the staff session. */
275
+ actor_id: z.string().trim().min(1).optional(),
276
+ /** Deprecated input; the route derives the grant ceiling from staff scopes. */
275
277
  operator_scopes: z.string().trim().default(""),
276
278
  optional_scopes: z.string().trim().default(""),
277
279
  })
@@ -292,15 +294,6 @@ export const appOAuthTokenSchema = z
292
294
  client_id: z.string().trim().min(1),
293
295
  client_secret: z.string().trim().optional(),
294
296
  }),
295
- z.object({
296
- grant_type: z.literal("urn:voyant:params:oauth:grant-type:actor-token-exchange"),
297
- installation_id: z.string().trim().min(1),
298
- viewer_id: z.string().trim().min(1),
299
- viewer_scopes: z.array(scopeSchema).default([]),
300
- contextual_scopes: z.array(scopeSchema).optional(),
301
- client_id: z.string().trim().min(1),
302
- client_secret: z.string().trim().optional(),
303
- }),
304
297
  ])
305
298
  .transform((input) => ({
306
299
  ...input,
@@ -1,9 +1,11 @@
1
- import type { VoyantAuthContext } from "@voyant-travel/core";
1
+ import type { VoyantAppContextConstraint, VoyantAuthContext } from "@voyant-travel/core";
2
2
  import type { AccessCatalog } from "@voyant-travel/types/api-keys";
3
3
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
4
4
  export interface AppOAuthServiceOptions {
5
5
  accessCatalog: AccessCatalog;
6
6
  deploymentId: string;
7
+ /** Managed confidential runtimes fail closed unless a client secret is registered. */
8
+ clientAuthentication?: "optional" | "required";
7
9
  now?: () => Date;
8
10
  }
9
11
  export interface AuthorizeAppInput {
@@ -37,6 +39,8 @@ export interface TokenExchangeInput {
37
39
  viewerId: string;
38
40
  viewerScopes: readonly string[];
39
41
  contextualScopes?: readonly string[];
42
+ /** Immutable host context signed into the extension session token. */
43
+ contextConstraint: VoyantAppContextConstraint;
40
44
  clientId: string;
41
45
  clientSecret?: string;
42
46
  }
@@ -64,3 +68,4 @@ export declare function createAppOAuthService(options: AppOAuthServiceOptions):
64
68
  };
65
69
  export declare function intersectAppTokenScopes(...sets: readonly (readonly string[])[]): string[];
66
70
  export declare function readStoredScopes(metadata: Record<string, unknown>): string[] | null;
71
+ export declare function readStoredAppContextConstraint(metadata: Record<string, unknown>): VoyantAppContextConstraint | null;