@voyant-travel/apps 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-boundary.d.ts +2 -0
- package/dist/api-runtime.d.ts +23 -11
- package/dist/api-runtime.js +40 -1
- package/dist/api-runtime.test.d.ts +1 -0
- package/dist/api-runtime.test.js +58 -0
- package/dist/app-api-contracts.d.ts +58 -8
- package/dist/app-api-contracts.js +102 -9
- package/dist/app-api-finance-routes.test.js +482 -2
- package/dist/app-api-routes.d.ts +6 -5
- package/dist/app-api-routes.js +131 -22
- package/dist/app-api-service.d.ts +33 -1
- package/dist/app-api-service.js +162 -0
- package/dist/compiler.test.js +7 -0
- package/dist/consent.d.ts +1 -0
- package/dist/consent.js +2 -2
- package/dist/contracts.d.ts +2 -26
- package/dist/contracts.js +4 -11
- package/dist/installation-service.d.ts +27 -3
- package/dist/installation-service.js +72 -3
- package/dist/oauth-installation.d.ts +101 -0
- package/dist/oauth-installation.js +143 -0
- package/dist/oauth-service.d.ts +9 -1
- package/dist/oauth-service.js +111 -97
- package/dist/oauth-service.test.js +121 -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 +8 -5
- package/dist/routes.js +43 -24
- package/dist/runtime-contributor.d.ts +13 -1
- package/dist/runtime-contributor.js +9 -1
- package/dist/runtime-contributor.test.d.ts +1 -0
- package/dist/runtime-contributor.test.js +55 -0
- package/dist/runtime-port.d.ts +29 -0
- package/dist/runtime-port.js +28 -0
- package/dist/schema.d.ts +170 -0
- package/dist/schema.js +18 -0
- package/dist/session-token-service.d.ts +4 -0
- package/dist/session-token-service.js +90 -28
- package/dist/session-token-service.test.d.ts +1 -0
- package/dist/session-token-service.test.js +187 -0
- package/dist/session-token.d.ts +13 -2
- package/dist/session-token.js +0 -0
- package/dist/session-token.test.js +51 -0
- package/dist/voyant.js +95 -1
- package/migrations/20260718205748_managed_installation_binding.sql +16 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +10 -5
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, appApiFinanceExternalReferenceUpsertSchema,
|
|
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();
|
|
@@ -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: {
|
|
@@ -1229,6 +1259,8 @@ export declare function createAppApiService(options?: AppApiServiceOptions): {
|
|
|
1229
1259
|
id: string;
|
|
1230
1260
|
appId: string;
|
|
1231
1261
|
deploymentId: string;
|
|
1262
|
+
workloadEnvironmentId: string | null;
|
|
1263
|
+
contractGeneration: number | null;
|
|
1232
1264
|
releaseId: string;
|
|
1233
1265
|
status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
|
|
1234
1266
|
namespace: string;
|
package/dist/app-api-service.js
CHANGED
|
@@ -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/compiler.test.js
CHANGED
|
@@ -3,6 +3,13 @@ import { compileAppManifest } from "./compiler.js";
|
|
|
3
3
|
import { appManifestSchema } from "./contracts.js";
|
|
4
4
|
import { validManifest } from "./test-fixtures.js";
|
|
5
5
|
describe("app manifest compiler", () => {
|
|
6
|
+
it("accepts truthful disclosure of publisher-custodied encrypted secrets", () => {
|
|
7
|
+
const parsed = appManifestSchema.parse({
|
|
8
|
+
...validManifest,
|
|
9
|
+
data: { ...validManifest.data, storesSecrets: true },
|
|
10
|
+
});
|
|
11
|
+
expect(parsed.data.storesSecrets).toBe(true);
|
|
12
|
+
});
|
|
6
13
|
it("accepts a closed v1 manifest and produces a stable digest", () => {
|
|
7
14
|
const first = compileAppManifest(validManifest);
|
|
8
15
|
const second = compileAppManifest({
|
package/dist/consent.d.ts
CHANGED
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 =
|
|
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
|
|
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
|
package/dist/contracts.d.ts
CHANGED
|
@@ -142,7 +142,7 @@ export declare const appManifestSchema: z.ZodPipe<z.ZodRecord<z.ZodString, z.Zod
|
|
|
142
142
|
financial: "financial";
|
|
143
143
|
}>>;
|
|
144
144
|
retention: z.ZodString;
|
|
145
|
-
storesSecrets: z.ZodDefault<z.
|
|
145
|
+
storesSecrets: z.ZodDefault<z.ZodBoolean>;
|
|
146
146
|
}, z.core.$strict>;
|
|
147
147
|
}, z.core.$strict>>;
|
|
148
148
|
export declare const createCustomAppRegistrationSchema: z.ZodObject<{
|
|
@@ -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
|
@@ -161,7 +161,7 @@ export const appManifestSchema = manifestDisallowedKeySchema.pipe(z
|
|
|
161
161
|
.object({
|
|
162
162
|
classifications: z.array(dataClassificationSchema).min(1),
|
|
163
163
|
retention: z.string().trim().min(1).max(280),
|
|
164
|
-
storesSecrets: z.
|
|
164
|
+
storesSecrets: z.boolean().default(false),
|
|
165
165
|
})
|
|
166
166
|
.strict(),
|
|
167
167
|
})
|
|
@@ -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
|
-
|
|
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,
|