@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
|
@@ -13,18 +13,22 @@
|
|
|
13
13
|
* Both paths write `app_audit_events` so issuance and exchange are auditable.
|
|
14
14
|
*/
|
|
15
15
|
import { ApiHttpError } from "@voyant-travel/hono";
|
|
16
|
-
import { and, eq, isNull } from "drizzle-orm";
|
|
16
|
+
import { and, eq, gt, isNull } from "drizzle-orm";
|
|
17
17
|
import { appAuditEvents, appInstallations, appSessionTokens, } from "./schema.js";
|
|
18
18
|
import { signAppSessionToken, verifyAppSessionToken, } from "./session-token.js";
|
|
19
19
|
export function createAppSessionTokenService(options) {
|
|
20
20
|
const now = () => options.now?.() ?? new Date();
|
|
21
21
|
async function issue(db, input) {
|
|
22
22
|
const installation = await requireActiveInstallation(db, input.installationId);
|
|
23
|
+
const managedBinding = await resolveManagedBinding(options.managedInstallation, installation.appId, installation.releaseId);
|
|
24
|
+
assertManagedBinding(installation, managedBinding);
|
|
23
25
|
const context = {
|
|
24
26
|
appId: installation.appId,
|
|
25
27
|
installationId: installation.id,
|
|
26
28
|
deploymentId: installation.deploymentId,
|
|
29
|
+
...(managedBinding ?? {}),
|
|
27
30
|
viewerId: input.viewerId,
|
|
31
|
+
viewerScopes: input.viewerScopes ?? [],
|
|
28
32
|
entity: input.entity ?? null,
|
|
29
33
|
slot: input.slot ?? null,
|
|
30
34
|
};
|
|
@@ -36,6 +40,8 @@ export function createAppSessionTokenService(options) {
|
|
|
36
40
|
installationId: installation.id,
|
|
37
41
|
appId: installation.appId,
|
|
38
42
|
deploymentId: installation.deploymentId,
|
|
43
|
+
workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
|
|
44
|
+
contractGeneration: managedBinding?.contractGeneration,
|
|
39
45
|
jti: signed.claims.jti,
|
|
40
46
|
viewerId: input.viewerId,
|
|
41
47
|
entityType: signed.claims.entity?.type ?? null,
|
|
@@ -52,7 +58,7 @@ export function createAppSessionTokenService(options) {
|
|
|
52
58
|
}
|
|
53
59
|
async function exchange(db, input) {
|
|
54
60
|
const verified = verifyAppSessionToken(input.token, options.secret, {
|
|
55
|
-
deploymentId: options.deploymentId,
|
|
61
|
+
deploymentId: options.managedInstallation ? undefined : options.deploymentId,
|
|
56
62
|
audience: input.clientId,
|
|
57
63
|
now,
|
|
58
64
|
});
|
|
@@ -63,37 +69,93 @@ export function createAppSessionTokenService(options) {
|
|
|
63
69
|
});
|
|
64
70
|
}
|
|
65
71
|
const { claims } = verified;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
return db.transaction(async (tx) => {
|
|
73
|
+
const installation = await requireActiveInstallation(tx, claims.installationId);
|
|
74
|
+
const managedBinding = await resolveManagedBinding(options.managedInstallation, installation.appId, installation.releaseId);
|
|
75
|
+
if (installation.appId !== claims.aud ||
|
|
76
|
+
(!managedBinding && installation.deploymentId !== claims.deploymentId) ||
|
|
77
|
+
!managedBindingMatches(installation, managedBinding) ||
|
|
78
|
+
!managedBindingMatches(claims, managedBinding)) {
|
|
79
|
+
throw new ApiHttpError("Session token installation context changed", {
|
|
80
|
+
status: 401,
|
|
81
|
+
code: "app_session_token_installation_mismatch",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Client authentication and token construction happen before consumption,
|
|
85
|
+
// but in the same transaction. If authentication/minting fails, or another
|
|
86
|
+
// exchange wins the JTI race, every credential side effect rolls back.
|
|
87
|
+
const tokens = await options.oauth.token(tx, {
|
|
88
|
+
grantType: "urn:voyant:params:oauth:grant-type:actor-token-exchange",
|
|
89
|
+
installationId: installation.id,
|
|
90
|
+
viewerId: claims.sub,
|
|
91
|
+
viewerScopes: intersectRequestedScopes(claims.viewerScopes, input.viewerScopes),
|
|
92
|
+
contextualScopes: input.contextualScopes,
|
|
93
|
+
contextConstraint: { entity: claims.entity, slot: claims.slot },
|
|
94
|
+
clientId: input.clientId,
|
|
95
|
+
clientSecret: input.clientSecret,
|
|
77
96
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
const consumed = await tx
|
|
98
|
+
.update(appSessionTokens)
|
|
99
|
+
.set({ consumedAt: now(), consumedByActorId: claims.sub })
|
|
100
|
+
.where(and(eq(appSessionTokens.jti, claims.jti), eq(appSessionTokens.installationId, installation.id), eq(appSessionTokens.appId, claims.aud), eq(appSessionTokens.deploymentId, claims.deploymentId), ...(managedBinding
|
|
101
|
+
? [
|
|
102
|
+
eq(appSessionTokens.workloadEnvironmentId, managedBinding.workloadEnvironmentId),
|
|
103
|
+
eq(appSessionTokens.contractGeneration, managedBinding.contractGeneration),
|
|
104
|
+
]
|
|
105
|
+
: []), eq(appSessionTokens.viewerId, claims.sub), isNull(appSessionTokens.consumedAt), gt(appSessionTokens.expiresAt, now())))
|
|
106
|
+
.returning({ id: appSessionTokens.id });
|
|
107
|
+
if (consumed.length === 0) {
|
|
108
|
+
throw new ApiHttpError("Session token has already been used", {
|
|
109
|
+
status: 401,
|
|
110
|
+
code: "app_session_token_replayed",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
await audit(tx, installation, claims.sub, "session-token.exchanged", {
|
|
114
|
+
jti: claims.jti,
|
|
115
|
+
slot: claims.slot,
|
|
116
|
+
entityType: claims.entity?.type ?? null,
|
|
117
|
+
entityId: claims.entity?.id ?? null,
|
|
118
|
+
});
|
|
119
|
+
return tokens;
|
|
92
120
|
});
|
|
93
|
-
return tokens;
|
|
94
121
|
}
|
|
95
122
|
return { issue, exchange };
|
|
96
123
|
}
|
|
124
|
+
async function resolveManagedBinding(authority, appId, releaseId) {
|
|
125
|
+
if (!authority)
|
|
126
|
+
return undefined;
|
|
127
|
+
const contract = await authority.resolveInstallationContract({ appId, releaseId });
|
|
128
|
+
if (!contract ||
|
|
129
|
+
!Number.isSafeInteger(contract.contractGeneration) ||
|
|
130
|
+
contract.contractGeneration <= 0) {
|
|
131
|
+
throw new ApiHttpError("Managed app installation contract is unavailable", {
|
|
132
|
+
status: 401,
|
|
133
|
+
code: "app_session_token_contract_unavailable",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
workloadEnvironmentId: authority.workloadEnvironmentId,
|
|
138
|
+
contractGeneration: contract.contractGeneration,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function managedBindingMatches(persisted, expected) {
|
|
142
|
+
if (!expected)
|
|
143
|
+
return true;
|
|
144
|
+
return (persisted.workloadEnvironmentId === expected.workloadEnvironmentId &&
|
|
145
|
+
persisted.contractGeneration === expected.contractGeneration);
|
|
146
|
+
}
|
|
147
|
+
function assertManagedBinding(persisted, expected) {
|
|
148
|
+
if (!managedBindingMatches(persisted, expected)) {
|
|
149
|
+
throw new ApiHttpError("App installation belongs to a different managed environment", {
|
|
150
|
+
status: 401,
|
|
151
|
+
code: "app_session_token_installation_mismatch",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function intersectRequestedScopes(trustedViewerScopes, requestedScopes) {
|
|
156
|
+
const trusted = new Set(trustedViewerScopes);
|
|
157
|
+
return Array.from(new Set(requestedScopes.filter((scope) => trusted.has(scope)))).sort();
|
|
158
|
+
}
|
|
97
159
|
async function requireActiveInstallation(db, installationId) {
|
|
98
160
|
const [installation] = await db
|
|
99
161
|
.select()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,187 @@
|
|
|
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(installationOverrides = {}) {
|
|
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
|
+
...installationOverrides,
|
|
14
|
+
};
|
|
15
|
+
const consume = vi.fn().mockResolvedValue([{ id: "session_1" }]);
|
|
16
|
+
const db = {
|
|
17
|
+
select: () => ({
|
|
18
|
+
from: () => ({ where: () => ({ limit: async () => [installation] }) }),
|
|
19
|
+
}),
|
|
20
|
+
insert: () => ({ values: async () => undefined }),
|
|
21
|
+
update: () => ({
|
|
22
|
+
set: () => ({ where: () => ({ returning: consume }) }),
|
|
23
|
+
}),
|
|
24
|
+
transaction: async (callback) => callback(db),
|
|
25
|
+
};
|
|
26
|
+
return { db, consume };
|
|
27
|
+
}
|
|
28
|
+
function databaseStub() {
|
|
29
|
+
return trackedDatabaseStub().db;
|
|
30
|
+
}
|
|
31
|
+
describe("app session token service", () => {
|
|
32
|
+
it("does not expose an app-asserted viewer scope grant on the public OAuth schema", () => {
|
|
33
|
+
expect(appOAuthTokenSchema.safeParse({
|
|
34
|
+
grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange",
|
|
35
|
+
installation_id: "inst_1",
|
|
36
|
+
viewer_id: "viewer_1",
|
|
37
|
+
viewer_scopes: ["finance-document-artifacts:write"],
|
|
38
|
+
client_id: "app_1",
|
|
39
|
+
}).success).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
it("intersects app-requested scopes with host-signed viewer authority", async () => {
|
|
42
|
+
const token = vi.fn().mockResolvedValue({ accessToken: "online" });
|
|
43
|
+
const service = createAppSessionTokenService({
|
|
44
|
+
secret: "test-deployment-session-secret-value-000000000000",
|
|
45
|
+
deploymentId: "dep_1",
|
|
46
|
+
oauth: { token },
|
|
47
|
+
now: () => new Date("2026-07-18T09:00:00.000Z"),
|
|
48
|
+
});
|
|
49
|
+
const db = databaseStub();
|
|
50
|
+
const issued = await service.issue(db, {
|
|
51
|
+
installationId: "inst_1",
|
|
52
|
+
viewerId: "viewer_1",
|
|
53
|
+
viewerScopes: ["finance-documents:read", "finance-document-artifacts:write"],
|
|
54
|
+
entity: { type: "invoice", id: "invoice_1" },
|
|
55
|
+
slot: "invoice.details.after-summary",
|
|
56
|
+
});
|
|
57
|
+
await service.exchange(db, {
|
|
58
|
+
token: issued.token,
|
|
59
|
+
clientId: "app_1",
|
|
60
|
+
clientSecret: "secret",
|
|
61
|
+
viewerScopes: [
|
|
62
|
+
"finance-documents:read",
|
|
63
|
+
"finance-document-artifacts:write",
|
|
64
|
+
"finance-external-sync:write",
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
expect(token).toHaveBeenCalledWith(db, expect.objectContaining({
|
|
68
|
+
viewerId: "viewer_1",
|
|
69
|
+
viewerScopes: ["finance-document-artifacts:write", "finance-documents:read"],
|
|
70
|
+
contextConstraint: {
|
|
71
|
+
entity: { type: "invoice", id: "invoice_1" },
|
|
72
|
+
slot: "invoice.details.after-summary",
|
|
73
|
+
},
|
|
74
|
+
}));
|
|
75
|
+
});
|
|
76
|
+
it("fails closed to no online scopes when issuance had no viewer authority", async () => {
|
|
77
|
+
const token = vi.fn().mockResolvedValue({ accessToken: "online" });
|
|
78
|
+
const service = createAppSessionTokenService({
|
|
79
|
+
secret: "test-deployment-session-secret-value-000000000000",
|
|
80
|
+
deploymentId: "dep_1",
|
|
81
|
+
oauth: { token },
|
|
82
|
+
now: () => new Date("2026-07-18T09:00:00.000Z"),
|
|
83
|
+
});
|
|
84
|
+
const db = databaseStub();
|
|
85
|
+
const issued = await service.issue(db, {
|
|
86
|
+
installationId: "inst_1",
|
|
87
|
+
viewerId: "viewer_1",
|
|
88
|
+
});
|
|
89
|
+
await service.exchange(db, {
|
|
90
|
+
token: issued.token,
|
|
91
|
+
clientId: "app_1",
|
|
92
|
+
viewerScopes: ["finance-document-artifacts:write"],
|
|
93
|
+
});
|
|
94
|
+
expect(token).toHaveBeenCalledWith(db, expect.objectContaining({ viewerScopes: [] }));
|
|
95
|
+
});
|
|
96
|
+
it("does not consume the JTI when app client authentication fails", async () => {
|
|
97
|
+
const token = vi.fn(async (_db, input) => {
|
|
98
|
+
if (input.clientSecret !== "right-secret") {
|
|
99
|
+
throw new ApiHttpError("Client authentication failed", {
|
|
100
|
+
status: 401,
|
|
101
|
+
code: "invalid_client",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return { accessToken: "online" };
|
|
105
|
+
});
|
|
106
|
+
const service = createAppSessionTokenService({
|
|
107
|
+
secret: "test-deployment-session-secret-value-000000000000",
|
|
108
|
+
deploymentId: "dep_1",
|
|
109
|
+
oauth: { token },
|
|
110
|
+
now: () => new Date("2026-07-18T09:00:00.000Z"),
|
|
111
|
+
});
|
|
112
|
+
const { db, consume } = trackedDatabaseStub();
|
|
113
|
+
const issued = await service.issue(db, {
|
|
114
|
+
installationId: "inst_1",
|
|
115
|
+
viewerId: "viewer_1",
|
|
116
|
+
});
|
|
117
|
+
await expect(service.exchange(db, {
|
|
118
|
+
token: issued.token,
|
|
119
|
+
clientId: "app_1",
|
|
120
|
+
clientSecret: "wrong-secret",
|
|
121
|
+
viewerScopes: [],
|
|
122
|
+
})).rejects.toMatchObject({ code: "invalid_client" });
|
|
123
|
+
expect(consume).not.toHaveBeenCalled();
|
|
124
|
+
await expect(service.exchange(db, {
|
|
125
|
+
token: issued.token,
|
|
126
|
+
clientId: "app_1",
|
|
127
|
+
clientSecret: "right-secret",
|
|
128
|
+
viewerScopes: [],
|
|
129
|
+
})).resolves.toEqual({ accessToken: "online" });
|
|
130
|
+
expect(consume).toHaveBeenCalledOnce();
|
|
131
|
+
});
|
|
132
|
+
it("leaves the JTI retryable when online credential minting fails", async () => {
|
|
133
|
+
const token = vi
|
|
134
|
+
.fn()
|
|
135
|
+
.mockRejectedValueOnce(new Error("credential insert failed"))
|
|
136
|
+
.mockResolvedValueOnce({ accessToken: "online" });
|
|
137
|
+
const service = createAppSessionTokenService({
|
|
138
|
+
secret: "test-deployment-session-secret-value-000000000000",
|
|
139
|
+
deploymentId: "dep_1",
|
|
140
|
+
oauth: { token },
|
|
141
|
+
now: () => new Date("2026-07-18T09:00:00.000Z"),
|
|
142
|
+
});
|
|
143
|
+
const { db, consume } = trackedDatabaseStub();
|
|
144
|
+
const issued = await service.issue(db, {
|
|
145
|
+
installationId: "inst_1",
|
|
146
|
+
viewerId: "viewer_1",
|
|
147
|
+
});
|
|
148
|
+
const input = {
|
|
149
|
+
token: issued.token,
|
|
150
|
+
clientId: "app_1",
|
|
151
|
+
viewerScopes: [],
|
|
152
|
+
};
|
|
153
|
+
await expect(service.exchange(db, input)).rejects.toThrow("credential insert failed");
|
|
154
|
+
expect(consume).not.toHaveBeenCalled();
|
|
155
|
+
await expect(service.exchange(db, input)).resolves.toEqual({ accessToken: "online" });
|
|
156
|
+
expect(consume).toHaveBeenCalledOnce();
|
|
157
|
+
});
|
|
158
|
+
it("keeps a managed session exchange valid across deployment rollouts", async () => {
|
|
159
|
+
const token = vi.fn().mockResolvedValue({ accessToken: "online" });
|
|
160
|
+
const installationAuthority = {
|
|
161
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
162
|
+
resolveInstallationContract: async () => ({ contractGeneration: 7 }),
|
|
163
|
+
};
|
|
164
|
+
const { db } = trackedDatabaseStub({
|
|
165
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
166
|
+
contractGeneration: 7,
|
|
167
|
+
});
|
|
168
|
+
const issued = await createAppSessionTokenService({
|
|
169
|
+
secret: "test-deployment-session-secret-value-000000000000",
|
|
170
|
+
deploymentId: "deployment_revision_1",
|
|
171
|
+
managedInstallation: installationAuthority,
|
|
172
|
+
oauth: { token },
|
|
173
|
+
now: () => new Date("2026-07-18T09:00:00.000Z"),
|
|
174
|
+
}).issue(db, { installationId: "inst_1", viewerId: "viewer_1" });
|
|
175
|
+
await expect(createAppSessionTokenService({
|
|
176
|
+
secret: "test-deployment-session-secret-value-000000000000",
|
|
177
|
+
deploymentId: "deployment_revision_2",
|
|
178
|
+
managedInstallation: installationAuthority,
|
|
179
|
+
oauth: { token },
|
|
180
|
+
now: () => new Date("2026-07-18T09:00:01.000Z"),
|
|
181
|
+
}).exchange(db, {
|
|
182
|
+
token: issued.token,
|
|
183
|
+
clientId: "app_1",
|
|
184
|
+
viewerScopes: [],
|
|
185
|
+
})).resolves.toEqual({ accessToken: "online" });
|
|
186
|
+
});
|
|
187
|
+
});
|
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. */
|
|
@@ -19,8 +19,12 @@ export interface AppSessionTokenContext {
|
|
|
19
19
|
appId: string;
|
|
20
20
|
installationId: string;
|
|
21
21
|
deploymentId: string;
|
|
22
|
+
workloadEnvironmentId?: string;
|
|
23
|
+
contractGeneration?: number;
|
|
22
24
|
/** The admin viewer the token acts on behalf of. */
|
|
23
25
|
viewerId: string;
|
|
26
|
+
/** Host-authenticated viewer permissions captured at issuance. */
|
|
27
|
+
viewerScopes?: readonly string[];
|
|
24
28
|
entity?: AppSessionTokenEntity | null;
|
|
25
29
|
slot?: string | null;
|
|
26
30
|
}
|
|
@@ -33,8 +37,12 @@ export interface AppSessionTokenClaims {
|
|
|
33
37
|
sub: string;
|
|
34
38
|
installationId: string;
|
|
35
39
|
deploymentId: string;
|
|
40
|
+
workloadEnvironmentId?: string;
|
|
41
|
+
contractGeneration?: number;
|
|
36
42
|
entity: AppSessionTokenEntity | null;
|
|
37
43
|
slot: string | null;
|
|
44
|
+
/** Host-authenticated viewer permissions; never supplied by the app backend. */
|
|
45
|
+
viewerScopes: string[];
|
|
38
46
|
iat: number;
|
|
39
47
|
exp: number;
|
|
40
48
|
/** Unique token id; the exchange store enforces single use by this value. */
|
|
@@ -63,6 +71,9 @@ export interface VerifyAppSessionTokenExpectations {
|
|
|
63
71
|
audience?: string;
|
|
64
72
|
/** Reject unless `deploymentId` equals this deployment. */
|
|
65
73
|
deploymentId?: string;
|
|
74
|
+
/** Managed workload-environment and contract binding; both are required together. */
|
|
75
|
+
workloadEnvironmentId?: string;
|
|
76
|
+
contractGeneration?: number;
|
|
66
77
|
now?: () => Date;
|
|
67
78
|
}
|
|
68
79
|
export type AppSessionTokenVerifyResult = {
|
|
@@ -72,7 +83,7 @@ export type AppSessionTokenVerifyResult = {
|
|
|
72
83
|
ok: false;
|
|
73
84
|
reason: AppSessionTokenRejectReason;
|
|
74
85
|
};
|
|
75
|
-
export type AppSessionTokenRejectReason = "malformed" | "bad_signature" | "expired" | "wrong_issuer" | "audience_mismatch" | "deployment_mismatch";
|
|
86
|
+
export type AppSessionTokenRejectReason = "malformed" | "bad_signature" | "expired" | "wrong_issuer" | "audience_mismatch" | "deployment_mismatch" | "workload_environment_mismatch" | "contract_generation_mismatch";
|
|
76
87
|
/**
|
|
77
88
|
* Verify a session token's signature, issuer, expiry, and (when supplied)
|
|
78
89
|
* audience and deployment binding. Returns the decoded claims on success or a
|
package/dist/session-token.js
CHANGED
|
Binary file
|
|
@@ -6,9 +6,15 @@ 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
|
};
|
|
13
|
+
const managedContext = {
|
|
14
|
+
...context,
|
|
15
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
16
|
+
contractGeneration: 3,
|
|
17
|
+
};
|
|
12
18
|
const at = (iso) => () => new Date(iso);
|
|
13
19
|
describe("app session token", () => {
|
|
14
20
|
it("round-trips claims, binds context, and carries a unique id", () => {
|
|
@@ -27,6 +33,10 @@ describe("app session token", () => {
|
|
|
27
33
|
expect(result.claims.installationId).toBe("apin_1");
|
|
28
34
|
expect(result.claims.entity).toEqual({ type: "booking", id: "book_1" });
|
|
29
35
|
expect(result.claims.slot).toBe("booking.details.header");
|
|
36
|
+
expect(result.claims.viewerScopes).toEqual([
|
|
37
|
+
"finance-document-artifacts:write",
|
|
38
|
+
"finance-documents:read",
|
|
39
|
+
]);
|
|
30
40
|
expect(result.claims.jti).toMatch(/^st_/);
|
|
31
41
|
});
|
|
32
42
|
it("mints a distinct jti per issuance", () => {
|
|
@@ -60,6 +70,47 @@ describe("app session token", () => {
|
|
|
60
70
|
});
|
|
61
71
|
expect(result).toEqual({ ok: false, reason: "deployment_mismatch" });
|
|
62
72
|
});
|
|
73
|
+
it("uses the stable workload environment rather than deployment revision in managed mode", () => {
|
|
74
|
+
const signed = signAppSessionToken({ ...managedContext, deploymentId: "deployment_revision_1" }, secret, { now: at("2026-07-17T10:00:00Z") });
|
|
75
|
+
const result = verifyAppSessionToken(signed.token, secret, {
|
|
76
|
+
audience: "app_1",
|
|
77
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
78
|
+
contractGeneration: 3,
|
|
79
|
+
now: at("2026-07-17T10:01:00Z"),
|
|
80
|
+
});
|
|
81
|
+
expect(result.ok).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
it("fails closed across managed workload environments and stale contract generations", () => {
|
|
84
|
+
const signed = signAppSessionToken(managedContext, secret, {
|
|
85
|
+
now: at("2026-07-17T10:00:00Z"),
|
|
86
|
+
});
|
|
87
|
+
expect(verifyAppSessionToken(signed.token, secret, {
|
|
88
|
+
workloadEnvironmentId: "workload_environment_other",
|
|
89
|
+
contractGeneration: 3,
|
|
90
|
+
now: at("2026-07-17T10:01:00Z"),
|
|
91
|
+
})).toEqual({ ok: false, reason: "workload_environment_mismatch" });
|
|
92
|
+
expect(verifyAppSessionToken(signed.token, secret, {
|
|
93
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
94
|
+
contractGeneration: 4,
|
|
95
|
+
now: at("2026-07-17T10:01:00Z"),
|
|
96
|
+
})).toEqual({ ok: false, reason: "contract_generation_mismatch" });
|
|
97
|
+
});
|
|
98
|
+
it("supports independent app contract generations in one workload environment", () => {
|
|
99
|
+
const first = signAppSessionToken({ ...managedContext, appId: "app_1", contractGeneration: 2 }, secret, { now: at("2026-07-17T10:00:00Z") });
|
|
100
|
+
const second = signAppSessionToken({ ...managedContext, appId: "app_2", contractGeneration: 9 }, secret, { now: at("2026-07-17T10:00:00Z") });
|
|
101
|
+
expect(verifyAppSessionToken(first.token, secret, {
|
|
102
|
+
audience: "app_1",
|
|
103
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
104
|
+
contractGeneration: 2,
|
|
105
|
+
now: at("2026-07-17T10:01:00Z"),
|
|
106
|
+
}).ok).toBe(true);
|
|
107
|
+
expect(verifyAppSessionToken(second.token, secret, {
|
|
108
|
+
audience: "app_2",
|
|
109
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
110
|
+
contractGeneration: 9,
|
|
111
|
+
now: at("2026-07-17T10:01:00Z"),
|
|
112
|
+
}).ok).toBe(true);
|
|
113
|
+
});
|
|
63
114
|
it("rejects a tampered signature", () => {
|
|
64
115
|
const signed = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
|
|
65
116
|
const tampered = `${signed.token.slice(0, -2)}xy`;
|
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",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
ALTER TABLE "app_installations" ADD COLUMN "workload_environment_id" text;--> statement-breakpoint
|
|
2
|
+
ALTER TABLE "app_installations" ADD COLUMN "contract_generation" integer;--> statement-breakpoint
|
|
3
|
+
ALTER TABLE "app_access_credentials" ADD COLUMN "workload_environment_id" text;--> statement-breakpoint
|
|
4
|
+
ALTER TABLE "app_access_credentials" ADD COLUMN "contract_generation" integer;--> statement-breakpoint
|
|
5
|
+
ALTER TABLE "app_oauth_authorization_codes" ADD COLUMN "workload_environment_id" text;--> statement-breakpoint
|
|
6
|
+
ALTER TABLE "app_oauth_authorization_codes" ADD COLUMN "contract_generation" integer;--> statement-breakpoint
|
|
7
|
+
ALTER TABLE "app_oauth_refresh_tokens" ADD COLUMN "workload_environment_id" text;--> statement-breakpoint
|
|
8
|
+
ALTER TABLE "app_oauth_refresh_tokens" ADD COLUMN "contract_generation" integer;--> statement-breakpoint
|
|
9
|
+
ALTER TABLE "app_session_tokens" ADD COLUMN "workload_environment_id" text;--> statement-breakpoint
|
|
10
|
+
ALTER TABLE "app_session_tokens" ADD COLUMN "contract_generation" integer;--> statement-breakpoint
|
|
11
|
+
ALTER TABLE "app_installations" ADD CONSTRAINT "app_installations_managed_binding_complete" CHECK (("workload_environment_id" IS NULL AND "contract_generation" IS NULL) OR ("workload_environment_id" IS NOT NULL AND "contract_generation" > 0));--> statement-breakpoint
|
|
12
|
+
ALTER TABLE "app_access_credentials" ADD CONSTRAINT "app_access_credentials_managed_binding_complete" CHECK (("workload_environment_id" IS NULL AND "contract_generation" IS NULL) OR ("workload_environment_id" IS NOT NULL AND "contract_generation" > 0));--> statement-breakpoint
|
|
13
|
+
ALTER TABLE "app_oauth_authorization_codes" ADD CONSTRAINT "app_oauth_codes_managed_binding_complete" CHECK (("workload_environment_id" IS NULL AND "contract_generation" IS NULL) OR ("workload_environment_id" IS NOT NULL AND "contract_generation" > 0));--> statement-breakpoint
|
|
14
|
+
ALTER TABLE "app_oauth_refresh_tokens" ADD CONSTRAINT "app_oauth_refresh_tokens_managed_binding_complete" CHECK (("workload_environment_id" IS NULL AND "contract_generation" IS NULL) OR ("workload_environment_id" IS NOT NULL AND "contract_generation" > 0));--> statement-breakpoint
|
|
15
|
+
ALTER TABLE "app_session_tokens" ADD CONSTRAINT "app_session_tokens_managed_binding_complete" CHECK (("workload_environment_id" IS NULL AND "contract_generation" IS NULL) OR ("workload_environment_id" IS NOT NULL AND "contract_generation" > 0));--> statement-breakpoint
|
|
16
|
+
CREATE UNIQUE INDEX "uidx_app_installations_workload_environment_app" ON "app_installations" USING btree ("workload_environment_id","app_id") WHERE "workload_environment_id" IS NOT NULL;
|
|
@@ -36,6 +36,13 @@
|
|
|
36
36
|
"when": 1784300400000,
|
|
37
37
|
"tag": "20260717150000_app_session_tokens",
|
|
38
38
|
"breakpoints": true
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"idx": 5,
|
|
42
|
+
"version": "7",
|
|
43
|
+
"when": 1784397468000,
|
|
44
|
+
"tag": "20260718205748_managed_installation_binding",
|
|
45
|
+
"breakpoints": true
|
|
39
46
|
}
|
|
40
47
|
]
|
|
41
|
-
}
|
|
48
|
+
}
|