@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.
- package/dist/api-runtime.d.ts +23 -11
- package/dist/api-runtime.js +38 -1
- package/dist/api-runtime.test.d.ts +1 -0
- package/dist/api-runtime.test.js +54 -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 +31 -1
- package/dist/app-api-service.js +162 -0
- package/dist/consent.d.ts +1 -0
- package/dist/consent.js +2 -2
- package/dist/contracts.d.ts +1 -25
- 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 +95 -1
- package/package.json +8 -3
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { ApiHttpError } from "@voyant-travel/hono";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { appOAuthTokenSchema } from "./contracts.js";
|
|
4
|
+
import { createAppSessionTokenService } from "./session-token-service.js";
|
|
5
|
+
function trackedDatabaseStub() {
|
|
6
|
+
const installation = {
|
|
7
|
+
id: "inst_1",
|
|
8
|
+
appId: "app_1",
|
|
9
|
+
releaseId: "rel_1",
|
|
10
|
+
deploymentId: "dep_1",
|
|
11
|
+
status: "active",
|
|
12
|
+
namespace: "app--one",
|
|
13
|
+
};
|
|
14
|
+
const consume = vi.fn().mockResolvedValue([{ id: "session_1" }]);
|
|
15
|
+
const db = {
|
|
16
|
+
select: () => ({
|
|
17
|
+
from: () => ({ where: () => ({ limit: async () => [installation] }) }),
|
|
18
|
+
}),
|
|
19
|
+
insert: () => ({ values: async () => undefined }),
|
|
20
|
+
update: () => ({
|
|
21
|
+
set: () => ({ where: () => ({ returning: consume }) }),
|
|
22
|
+
}),
|
|
23
|
+
transaction: async (callback) => callback(db),
|
|
24
|
+
};
|
|
25
|
+
return { db, consume };
|
|
26
|
+
}
|
|
27
|
+
function databaseStub() {
|
|
28
|
+
return trackedDatabaseStub().db;
|
|
29
|
+
}
|
|
30
|
+
describe("app session token service", () => {
|
|
31
|
+
it("does not expose an app-asserted viewer scope grant on the public OAuth schema", () => {
|
|
32
|
+
expect(appOAuthTokenSchema.safeParse({
|
|
33
|
+
grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange",
|
|
34
|
+
installation_id: "inst_1",
|
|
35
|
+
viewer_id: "viewer_1",
|
|
36
|
+
viewer_scopes: ["finance-document-artifacts:write"],
|
|
37
|
+
client_id: "app_1",
|
|
38
|
+
}).success).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
it("intersects app-requested scopes with host-signed viewer authority", async () => {
|
|
41
|
+
const token = vi.fn().mockResolvedValue({ accessToken: "online" });
|
|
42
|
+
const service = createAppSessionTokenService({
|
|
43
|
+
secret: "test-deployment-session-secret-value-000000000000",
|
|
44
|
+
deploymentId: "dep_1",
|
|
45
|
+
oauth: { token },
|
|
46
|
+
now: () => new Date("2026-07-18T09:00:00.000Z"),
|
|
47
|
+
});
|
|
48
|
+
const db = databaseStub();
|
|
49
|
+
const issued = await service.issue(db, {
|
|
50
|
+
installationId: "inst_1",
|
|
51
|
+
viewerId: "viewer_1",
|
|
52
|
+
viewerScopes: ["finance-documents:read", "finance-document-artifacts:write"],
|
|
53
|
+
entity: { type: "invoice", id: "invoice_1" },
|
|
54
|
+
slot: "invoice.details.after-summary",
|
|
55
|
+
});
|
|
56
|
+
await service.exchange(db, {
|
|
57
|
+
token: issued.token,
|
|
58
|
+
clientId: "app_1",
|
|
59
|
+
clientSecret: "secret",
|
|
60
|
+
viewerScopes: [
|
|
61
|
+
"finance-documents:read",
|
|
62
|
+
"finance-document-artifacts:write",
|
|
63
|
+
"finance-external-sync:write",
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
expect(token).toHaveBeenCalledWith(db, expect.objectContaining({
|
|
67
|
+
viewerId: "viewer_1",
|
|
68
|
+
viewerScopes: ["finance-document-artifacts:write", "finance-documents:read"],
|
|
69
|
+
contextConstraint: {
|
|
70
|
+
entity: { type: "invoice", id: "invoice_1" },
|
|
71
|
+
slot: "invoice.details.after-summary",
|
|
72
|
+
},
|
|
73
|
+
}));
|
|
74
|
+
});
|
|
75
|
+
it("fails closed to no online scopes when issuance had no viewer authority", async () => {
|
|
76
|
+
const token = vi.fn().mockResolvedValue({ accessToken: "online" });
|
|
77
|
+
const service = createAppSessionTokenService({
|
|
78
|
+
secret: "test-deployment-session-secret-value-000000000000",
|
|
79
|
+
deploymentId: "dep_1",
|
|
80
|
+
oauth: { token },
|
|
81
|
+
now: () => new Date("2026-07-18T09:00:00.000Z"),
|
|
82
|
+
});
|
|
83
|
+
const db = databaseStub();
|
|
84
|
+
const issued = await service.issue(db, {
|
|
85
|
+
installationId: "inst_1",
|
|
86
|
+
viewerId: "viewer_1",
|
|
87
|
+
});
|
|
88
|
+
await service.exchange(db, {
|
|
89
|
+
token: issued.token,
|
|
90
|
+
clientId: "app_1",
|
|
91
|
+
viewerScopes: ["finance-document-artifacts:write"],
|
|
92
|
+
});
|
|
93
|
+
expect(token).toHaveBeenCalledWith(db, expect.objectContaining({ viewerScopes: [] }));
|
|
94
|
+
});
|
|
95
|
+
it("does not consume the JTI when app client authentication fails", async () => {
|
|
96
|
+
const token = vi.fn(async (_db, input) => {
|
|
97
|
+
if (input.clientSecret !== "right-secret") {
|
|
98
|
+
throw new ApiHttpError("Client authentication failed", {
|
|
99
|
+
status: 401,
|
|
100
|
+
code: "invalid_client",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { accessToken: "online" };
|
|
104
|
+
});
|
|
105
|
+
const service = createAppSessionTokenService({
|
|
106
|
+
secret: "test-deployment-session-secret-value-000000000000",
|
|
107
|
+
deploymentId: "dep_1",
|
|
108
|
+
oauth: { token },
|
|
109
|
+
now: () => new Date("2026-07-18T09:00:00.000Z"),
|
|
110
|
+
});
|
|
111
|
+
const { db, consume } = trackedDatabaseStub();
|
|
112
|
+
const issued = await service.issue(db, {
|
|
113
|
+
installationId: "inst_1",
|
|
114
|
+
viewerId: "viewer_1",
|
|
115
|
+
});
|
|
116
|
+
await expect(service.exchange(db, {
|
|
117
|
+
token: issued.token,
|
|
118
|
+
clientId: "app_1",
|
|
119
|
+
clientSecret: "wrong-secret",
|
|
120
|
+
viewerScopes: [],
|
|
121
|
+
})).rejects.toMatchObject({ code: "invalid_client" });
|
|
122
|
+
expect(consume).not.toHaveBeenCalled();
|
|
123
|
+
await expect(service.exchange(db, {
|
|
124
|
+
token: issued.token,
|
|
125
|
+
clientId: "app_1",
|
|
126
|
+
clientSecret: "right-secret",
|
|
127
|
+
viewerScopes: [],
|
|
128
|
+
})).resolves.toEqual({ accessToken: "online" });
|
|
129
|
+
expect(consume).toHaveBeenCalledOnce();
|
|
130
|
+
});
|
|
131
|
+
it("leaves the JTI retryable when online credential minting fails", async () => {
|
|
132
|
+
const token = vi
|
|
133
|
+
.fn()
|
|
134
|
+
.mockRejectedValueOnce(new Error("credential insert failed"))
|
|
135
|
+
.mockResolvedValueOnce({ accessToken: "online" });
|
|
136
|
+
const service = createAppSessionTokenService({
|
|
137
|
+
secret: "test-deployment-session-secret-value-000000000000",
|
|
138
|
+
deploymentId: "dep_1",
|
|
139
|
+
oauth: { token },
|
|
140
|
+
now: () => new Date("2026-07-18T09:00:00.000Z"),
|
|
141
|
+
});
|
|
142
|
+
const { db, consume } = trackedDatabaseStub();
|
|
143
|
+
const issued = await service.issue(db, {
|
|
144
|
+
installationId: "inst_1",
|
|
145
|
+
viewerId: "viewer_1",
|
|
146
|
+
});
|
|
147
|
+
const input = {
|
|
148
|
+
token: issued.token,
|
|
149
|
+
clientId: "app_1",
|
|
150
|
+
viewerScopes: [],
|
|
151
|
+
};
|
|
152
|
+
await expect(service.exchange(db, input)).rejects.toThrow("credential insert failed");
|
|
153
|
+
expect(consume).not.toHaveBeenCalled();
|
|
154
|
+
await expect(service.exchange(db, input)).resolves.toEqual({ accessToken: "online" });
|
|
155
|
+
expect(consume).toHaveBeenCalledOnce();
|
|
156
|
+
});
|
|
157
|
+
});
|
package/dist/session-token.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export declare const APP_SESSION_TOKEN_PREFIX = "vsess_";
|
|
|
3
3
|
* HKDF context label under which admin session tokens are signed. Bumping the
|
|
4
4
|
* version suffix invalidates every outstanding token.
|
|
5
5
|
*/
|
|
6
|
-
export declare const APP_SESSION_TOKEN_KEY_CONTEXT = "voyant:app-session-token:
|
|
6
|
+
export declare const APP_SESSION_TOKEN_KEY_CONTEXT = "voyant:app-session-token:v2";
|
|
7
7
|
/** Default token lifetime — short enough to make replay low value. */
|
|
8
8
|
export declare const APP_SESSION_TOKEN_TTL_SECONDS = 120;
|
|
9
9
|
/** Stable issuer claim, distinguishing these from every other Voyant token. */
|
|
@@ -21,6 +21,8 @@ export interface AppSessionTokenContext {
|
|
|
21
21
|
deploymentId: string;
|
|
22
22
|
/** The admin viewer the token acts on behalf of. */
|
|
23
23
|
viewerId: string;
|
|
24
|
+
/** Host-authenticated viewer permissions captured at issuance. */
|
|
25
|
+
viewerScopes?: readonly string[];
|
|
24
26
|
entity?: AppSessionTokenEntity | null;
|
|
25
27
|
slot?: string | null;
|
|
26
28
|
}
|
|
@@ -35,6 +37,8 @@ export interface AppSessionTokenClaims {
|
|
|
35
37
|
deploymentId: string;
|
|
36
38
|
entity: AppSessionTokenEntity | null;
|
|
37
39
|
slot: string | null;
|
|
40
|
+
/** Host-authenticated viewer permissions; never supplied by the app backend. */
|
|
41
|
+
viewerScopes: string[];
|
|
38
42
|
iat: number;
|
|
39
43
|
exp: number;
|
|
40
44
|
/** Unique token id; the exchange store enforces single use by this value. */
|
package/dist/session-token.js
CHANGED
|
Binary file
|
|
@@ -6,6 +6,7 @@ const context = {
|
|
|
6
6
|
installationId: "apin_1",
|
|
7
7
|
deploymentId: "dep_1",
|
|
8
8
|
viewerId: "usr_1",
|
|
9
|
+
viewerScopes: ["finance-documents:read", "finance-document-artifacts:write"],
|
|
9
10
|
entity: { type: "booking", id: "book_1" },
|
|
10
11
|
slot: "booking.details.header",
|
|
11
12
|
};
|
|
@@ -27,6 +28,10 @@ describe("app session token", () => {
|
|
|
27
28
|
expect(result.claims.installationId).toBe("apin_1");
|
|
28
29
|
expect(result.claims.entity).toEqual({ type: "booking", id: "book_1" });
|
|
29
30
|
expect(result.claims.slot).toBe("booking.details.header");
|
|
31
|
+
expect(result.claims.viewerScopes).toEqual([
|
|
32
|
+
"finance-document-artifacts:write",
|
|
33
|
+
"finance-documents:read",
|
|
34
|
+
]);
|
|
30
35
|
expect(result.claims.jti).toMatch(/^st_/);
|
|
31
36
|
});
|
|
32
37
|
it("mints a distinct jti per issuance", () => {
|
package/dist/voyant.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { defineModule, requirePort } from "@voyant-travel/core/project";
|
|
1
|
+
import { defineModule, providePort, requirePort } from "@voyant-travel/core/project";
|
|
2
2
|
import { customFieldValueLifecycleRuntimePort, customFieldValueOperationsRuntimePort, } from "@voyant-travel/core/runtime-port";
|
|
3
3
|
import { financeAppApiRuntimePort } from "@voyant-travel/finance-contracts/runtime-port";
|
|
4
|
+
import { appsManagedAuthRuntimePort } from "./runtime-port.js";
|
|
4
5
|
const appsAdminRuntime = {
|
|
5
6
|
entry: "@voyant-travel/apps-react/admin",
|
|
6
7
|
export: "createSelectedAppsAdminExtension",
|
|
@@ -29,7 +30,11 @@ export const appsVoyantModule = defineModule({
|
|
|
29
30
|
cardinality: "many",
|
|
30
31
|
}),
|
|
31
32
|
requirePort(financeAppApiRuntimePort, { optional: true }),
|
|
33
|
+
requirePort(appsManagedAuthRuntimePort, { optional: true }),
|
|
32
34
|
],
|
|
35
|
+
provides: {
|
|
36
|
+
ports: [providePort(appsManagedAuthRuntimePort)],
|
|
37
|
+
},
|
|
33
38
|
api: [
|
|
34
39
|
{
|
|
35
40
|
id: "@voyant-travel/apps#api.admin",
|
|
@@ -56,6 +61,27 @@ export const appsVoyantModule = defineModule({
|
|
|
56
61
|
source: "./migrations",
|
|
57
62
|
},
|
|
58
63
|
],
|
|
64
|
+
config: [
|
|
65
|
+
{
|
|
66
|
+
id: "@voyant-travel/apps#config.runtime-audience",
|
|
67
|
+
key: "VOYANT_APP_RUNTIME_AUDIENCE",
|
|
68
|
+
required: false,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "@voyant-travel/apps#config.session-token-ttl-seconds",
|
|
72
|
+
key: "VOYANT_APP_SESSION_TOKEN_TTL_SECONDS",
|
|
73
|
+
required: false,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
secrets: [
|
|
77
|
+
{
|
|
78
|
+
id: "@voyant-travel/apps#secret.session-token-signing-secret",
|
|
79
|
+
key: "VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET",
|
|
80
|
+
required: false,
|
|
81
|
+
description: "Signing material for short-lived remote extension session tokens.",
|
|
82
|
+
rotation: "replace-only",
|
|
83
|
+
},
|
|
84
|
+
],
|
|
59
85
|
events: [
|
|
60
86
|
{
|
|
61
87
|
id: "@voyant-travel/apps#event.installation-active",
|
|
@@ -300,6 +326,74 @@ export const appsVoyantModule = defineModule({
|
|
|
300
326
|
],
|
|
301
327
|
wildcard: "explicit-resource",
|
|
302
328
|
},
|
|
329
|
+
{
|
|
330
|
+
id: "@voyant-travel/apps#access.finance-document-artifacts",
|
|
331
|
+
resource: "finance-document-artifacts",
|
|
332
|
+
label: "Finance document artifacts",
|
|
333
|
+
description: "Attach private provider-generated renditions to finance documents.",
|
|
334
|
+
remoteSafe: true,
|
|
335
|
+
actions: [
|
|
336
|
+
{
|
|
337
|
+
action: "write",
|
|
338
|
+
label: "Attach finance document artifacts",
|
|
339
|
+
description: "Upload and bind a bounded provider-generated finance document PDF.",
|
|
340
|
+
sensitive: true,
|
|
341
|
+
wildcard: "explicit",
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
wildcard: "explicit-resource",
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
id: "@voyant-travel/apps#access.finance-external-sync",
|
|
348
|
+
resource: "finance-external-sync",
|
|
349
|
+
label: "Finance external synchronization",
|
|
350
|
+
description: "Report provider-neutral synchronization outcomes for finance documents.",
|
|
351
|
+
remoteSafe: true,
|
|
352
|
+
actions: [
|
|
353
|
+
{
|
|
354
|
+
action: "write",
|
|
355
|
+
label: "Report finance synchronization",
|
|
356
|
+
description: "Record ordered success, retryable-failure, or terminal-failure state.",
|
|
357
|
+
sensitive: true,
|
|
358
|
+
wildcard: "explicit",
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
wildcard: "explicit-resource",
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
id: "@voyant-travel/apps#access.finance-external-lifecycle",
|
|
365
|
+
resource: "finance-external-lifecycle",
|
|
366
|
+
label: "Finance external lifecycle",
|
|
367
|
+
description: "Report provider-neutral lifecycle facts for finance documents.",
|
|
368
|
+
remoteSafe: true,
|
|
369
|
+
actions: [
|
|
370
|
+
{
|
|
371
|
+
action: "write",
|
|
372
|
+
label: "Report finance lifecycle",
|
|
373
|
+
description: "Record an ordered conversion or void observation.",
|
|
374
|
+
sensitive: true,
|
|
375
|
+
wildcard: "explicit",
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
wildcard: "explicit-resource",
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
id: "@voyant-travel/apps#access.finance-settlement-observations",
|
|
382
|
+
resource: "finance-settlement-observations",
|
|
383
|
+
label: "Finance settlement observations",
|
|
384
|
+
description: "Report provider-neutral settlement evidence without creating payments.",
|
|
385
|
+
remoteSafe: true,
|
|
386
|
+
actions: [
|
|
387
|
+
{
|
|
388
|
+
action: "write",
|
|
389
|
+
label: "Report settlement observations",
|
|
390
|
+
description: "Record ordered partial or paid observations and payment identifiers.",
|
|
391
|
+
sensitive: true,
|
|
392
|
+
wildcard: "explicit",
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
wildcard: "explicit-resource",
|
|
396
|
+
},
|
|
303
397
|
{
|
|
304
398
|
id: "@voyant-travel/apps#access.apps",
|
|
305
399
|
resource: "apps",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyant-travel/apps",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -89,6 +89,11 @@
|
|
|
89
89
|
"import": "./dist/runtime-contributor.js",
|
|
90
90
|
"default": "./dist/runtime-contributor.js"
|
|
91
91
|
},
|
|
92
|
+
"./runtime-port": {
|
|
93
|
+
"types": "./dist/runtime-port.d.ts",
|
|
94
|
+
"import": "./dist/runtime-port.js",
|
|
95
|
+
"default": "./dist/runtime-port.js"
|
|
96
|
+
},
|
|
92
97
|
"./schema": {
|
|
93
98
|
"types": "./dist/schema.d.ts",
|
|
94
99
|
"import": "./dist/schema.js",
|
|
@@ -124,10 +129,10 @@
|
|
|
124
129
|
"@voyant-travel/admin": "^0.127.0",
|
|
125
130
|
"@voyant-travel/admin-extension-sdk": "^0.2.0",
|
|
126
131
|
"@voyant-travel/core": "^0.127.0",
|
|
127
|
-
"@voyant-travel/custom-fields": "^0.2.
|
|
132
|
+
"@voyant-travel/custom-fields": "^0.2.4",
|
|
128
133
|
"@voyant-travel/finance-contracts": "^0.107.0",
|
|
129
134
|
"@voyant-travel/db": "^0.114.13",
|
|
130
|
-
"@voyant-travel/hono": "^0.
|
|
135
|
+
"@voyant-travel/hono": "^0.129.0",
|
|
131
136
|
"@voyant-travel/types": "^0.109.4",
|
|
132
137
|
"@voyant-travel/webhook-delivery": "^0.4.2"
|
|
133
138
|
},
|