@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.
- package/dist/api-runtime.d.ts +23 -11
- package/dist/api-runtime.js +44 -2
- package/dist/api-runtime.test.d.ts +1 -0
- package/dist/api-runtime.test.js +54 -0
- package/dist/app-api-contracts.d.ts +84 -7
- package/dist/app-api-contracts.js +130 -9
- package/dist/app-api-finance-routes.test.d.ts +1 -0
- package/dist/app-api-finance-routes.test.js +633 -0
- package/dist/app-api-routes.d.ts +6 -5
- package/dist/app-api-routes.js +144 -22
- package/dist/app-api-service.d.ts +49 -4
- package/dist/app-api-service.js +237 -0
- package/dist/app-api-service.test.js +52 -0
- package/dist/consent.d.ts +1 -0
- package/dist/consent.js +2 -2
- package/dist/contracts.d.ts +4 -28
- package/dist/contracts.js +3 -10
- package/dist/oauth-service.d.ts +6 -1
- package/dist/oauth-service.js +41 -4
- package/dist/oauth-service.test.js +39 -2
- package/dist/routes-openapi.d.ts +1 -25
- package/dist/routes-viewer-scopes.test.d.ts +1 -0
- package/dist/routes-viewer-scopes.test.js +46 -0
- package/dist/routes.d.ts +5 -5
- package/dist/routes.js +41 -24
- package/dist/runtime-contributor.d.ts +9 -1
- package/dist/runtime-contributor.js +25 -2
- package/dist/runtime-contributor.test.d.ts +1 -0
- package/dist/runtime-contributor.test.js +52 -0
- package/dist/runtime-port.d.ts +8 -0
- package/dist/runtime-port.js +22 -0
- package/dist/session-token-service.d.ts +2 -0
- package/dist/session-token-service.js +44 -27
- package/dist/session-token-service.test.d.ts +1 -0
- package/dist/session-token-service.test.js +157 -0
- package/dist/session-token.d.ts +5 -1
- package/dist/session-token.js +0 -0
- package/dist/session-token.test.js +5 -0
- package/dist/voyant.js +136 -1
- package/package.json +9 -3
package/dist/app-api-routes.js
CHANGED
|
@@ -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,
|
|
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
|
|
37
|
-
|
|
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: {
|
package/dist/app-api-service.js
CHANGED
|
@@ -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