@voyant-travel/apps 0.8.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.js +2 -0
- package/dist/api-runtime.test.js +4 -0
- package/dist/app-api-service.d.ts +2 -0
- package/dist/compiler.test.js +7 -0
- package/dist/contracts.d.ts +1 -1
- package/dist/contracts.js +1 -1
- 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 +3 -0
- package/dist/oauth-service.js +70 -93
- package/dist/oauth-service.test.js +83 -1
- package/dist/routes.d.ts +3 -0
- package/dist/routes.js +2 -0
- package/dist/runtime-contributor.d.ts +5 -1
- package/dist/runtime-contributor.js +6 -21
- package/dist/runtime-contributor.test.js +28 -25
- package/dist/runtime-port.d.ts +21 -0
- package/dist/runtime-port.js +6 -0
- package/dist/schema.d.ts +170 -0
- package/dist/schema.js +18 -0
- package/dist/session-token-service.d.ts +2 -0
- package/dist/session-token-service.js +48 -3
- package/dist/session-token-service.test.js +31 -1
- package/dist/session-token.d.ts +8 -1
- package/dist/session-token.js +0 -0
- package/dist/session-token.test.js +46 -0
- package/migrations/20260718205748_managed_installation_binding.sql +16 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +4 -4
package/dist/schema.d.ts
CHANGED
|
@@ -995,6 +995,40 @@ export declare const appInstallations: import("drizzle-orm/pg-core").PgTableWith
|
|
|
995
995
|
identity: undefined;
|
|
996
996
|
generated: undefined;
|
|
997
997
|
}, {}, {}>;
|
|
998
|
+
workloadEnvironmentId: import("drizzle-orm/pg-core").PgColumn<{
|
|
999
|
+
name: "workload_environment_id";
|
|
1000
|
+
tableName: "app_installations";
|
|
1001
|
+
dataType: "string";
|
|
1002
|
+
columnType: "PgText";
|
|
1003
|
+
data: string;
|
|
1004
|
+
driverParam: string;
|
|
1005
|
+
notNull: false;
|
|
1006
|
+
hasDefault: false;
|
|
1007
|
+
isPrimaryKey: false;
|
|
1008
|
+
isAutoincrement: false;
|
|
1009
|
+
hasRuntimeDefault: false;
|
|
1010
|
+
enumValues: [string, ...string[]];
|
|
1011
|
+
baseColumn: never;
|
|
1012
|
+
identity: undefined;
|
|
1013
|
+
generated: undefined;
|
|
1014
|
+
}, {}, {}>;
|
|
1015
|
+
contractGeneration: import("drizzle-orm/pg-core").PgColumn<{
|
|
1016
|
+
name: "contract_generation";
|
|
1017
|
+
tableName: "app_installations";
|
|
1018
|
+
dataType: "number";
|
|
1019
|
+
columnType: "PgInteger";
|
|
1020
|
+
data: number;
|
|
1021
|
+
driverParam: string | number;
|
|
1022
|
+
notNull: false;
|
|
1023
|
+
hasDefault: false;
|
|
1024
|
+
isPrimaryKey: false;
|
|
1025
|
+
isAutoincrement: false;
|
|
1026
|
+
hasRuntimeDefault: false;
|
|
1027
|
+
enumValues: undefined;
|
|
1028
|
+
baseColumn: never;
|
|
1029
|
+
identity: undefined;
|
|
1030
|
+
generated: undefined;
|
|
1031
|
+
}, {}, {}>;
|
|
998
1032
|
releaseId: import("drizzle-orm/pg-core").PgColumn<{
|
|
999
1033
|
name: "release_id";
|
|
1000
1034
|
tableName: "app_installations";
|
|
@@ -1519,6 +1553,40 @@ export declare const appAccessCredentials: import("drizzle-orm/pg-core").PgTable
|
|
|
1519
1553
|
identity: undefined;
|
|
1520
1554
|
generated: undefined;
|
|
1521
1555
|
}, {}, {}>;
|
|
1556
|
+
workloadEnvironmentId: import("drizzle-orm/pg-core").PgColumn<{
|
|
1557
|
+
name: "workload_environment_id";
|
|
1558
|
+
tableName: "app_access_credentials";
|
|
1559
|
+
dataType: "string";
|
|
1560
|
+
columnType: "PgText";
|
|
1561
|
+
data: string;
|
|
1562
|
+
driverParam: string;
|
|
1563
|
+
notNull: false;
|
|
1564
|
+
hasDefault: false;
|
|
1565
|
+
isPrimaryKey: false;
|
|
1566
|
+
isAutoincrement: false;
|
|
1567
|
+
hasRuntimeDefault: false;
|
|
1568
|
+
enumValues: [string, ...string[]];
|
|
1569
|
+
baseColumn: never;
|
|
1570
|
+
identity: undefined;
|
|
1571
|
+
generated: undefined;
|
|
1572
|
+
}, {}, {}>;
|
|
1573
|
+
contractGeneration: import("drizzle-orm/pg-core").PgColumn<{
|
|
1574
|
+
name: "contract_generation";
|
|
1575
|
+
tableName: "app_access_credentials";
|
|
1576
|
+
dataType: "number";
|
|
1577
|
+
columnType: "PgInteger";
|
|
1578
|
+
data: number;
|
|
1579
|
+
driverParam: string | number;
|
|
1580
|
+
notNull: false;
|
|
1581
|
+
hasDefault: false;
|
|
1582
|
+
isPrimaryKey: false;
|
|
1583
|
+
isAutoincrement: false;
|
|
1584
|
+
hasRuntimeDefault: false;
|
|
1585
|
+
enumValues: undefined;
|
|
1586
|
+
baseColumn: never;
|
|
1587
|
+
identity: undefined;
|
|
1588
|
+
generated: undefined;
|
|
1589
|
+
}, {}, {}>;
|
|
1522
1590
|
tokenMode: import("drizzle-orm/pg-core").PgColumn<{
|
|
1523
1591
|
name: "token_mode";
|
|
1524
1592
|
tableName: "app_access_credentials";
|
|
@@ -1766,6 +1834,40 @@ export declare const appOAuthAuthorizationCodes: import("drizzle-orm/pg-core").P
|
|
|
1766
1834
|
identity: undefined;
|
|
1767
1835
|
generated: undefined;
|
|
1768
1836
|
}, {}, {}>;
|
|
1837
|
+
workloadEnvironmentId: import("drizzle-orm/pg-core").PgColumn<{
|
|
1838
|
+
name: "workload_environment_id";
|
|
1839
|
+
tableName: "app_oauth_authorization_codes";
|
|
1840
|
+
dataType: "string";
|
|
1841
|
+
columnType: "PgText";
|
|
1842
|
+
data: string;
|
|
1843
|
+
driverParam: string;
|
|
1844
|
+
notNull: false;
|
|
1845
|
+
hasDefault: false;
|
|
1846
|
+
isPrimaryKey: false;
|
|
1847
|
+
isAutoincrement: false;
|
|
1848
|
+
hasRuntimeDefault: false;
|
|
1849
|
+
enumValues: [string, ...string[]];
|
|
1850
|
+
baseColumn: never;
|
|
1851
|
+
identity: undefined;
|
|
1852
|
+
generated: undefined;
|
|
1853
|
+
}, {}, {}>;
|
|
1854
|
+
contractGeneration: import("drizzle-orm/pg-core").PgColumn<{
|
|
1855
|
+
name: "contract_generation";
|
|
1856
|
+
tableName: "app_oauth_authorization_codes";
|
|
1857
|
+
dataType: "number";
|
|
1858
|
+
columnType: "PgInteger";
|
|
1859
|
+
data: number;
|
|
1860
|
+
driverParam: string | number;
|
|
1861
|
+
notNull: false;
|
|
1862
|
+
hasDefault: false;
|
|
1863
|
+
isPrimaryKey: false;
|
|
1864
|
+
isAutoincrement: false;
|
|
1865
|
+
hasRuntimeDefault: false;
|
|
1866
|
+
enumValues: undefined;
|
|
1867
|
+
baseColumn: never;
|
|
1868
|
+
identity: undefined;
|
|
1869
|
+
generated: undefined;
|
|
1870
|
+
}, {}, {}>;
|
|
1769
1871
|
codeHash: import("drizzle-orm/pg-core").PgColumn<{
|
|
1770
1872
|
name: "code_hash";
|
|
1771
1873
|
tableName: "app_oauth_authorization_codes";
|
|
@@ -2051,6 +2153,40 @@ export declare const appOAuthRefreshTokens: import("drizzle-orm/pg-core").PgTabl
|
|
|
2051
2153
|
identity: undefined;
|
|
2052
2154
|
generated: undefined;
|
|
2053
2155
|
}, {}, {}>;
|
|
2156
|
+
workloadEnvironmentId: import("drizzle-orm/pg-core").PgColumn<{
|
|
2157
|
+
name: "workload_environment_id";
|
|
2158
|
+
tableName: "app_oauth_refresh_tokens";
|
|
2159
|
+
dataType: "string";
|
|
2160
|
+
columnType: "PgText";
|
|
2161
|
+
data: string;
|
|
2162
|
+
driverParam: string;
|
|
2163
|
+
notNull: false;
|
|
2164
|
+
hasDefault: false;
|
|
2165
|
+
isPrimaryKey: false;
|
|
2166
|
+
isAutoincrement: false;
|
|
2167
|
+
hasRuntimeDefault: false;
|
|
2168
|
+
enumValues: [string, ...string[]];
|
|
2169
|
+
baseColumn: never;
|
|
2170
|
+
identity: undefined;
|
|
2171
|
+
generated: undefined;
|
|
2172
|
+
}, {}, {}>;
|
|
2173
|
+
contractGeneration: import("drizzle-orm/pg-core").PgColumn<{
|
|
2174
|
+
name: "contract_generation";
|
|
2175
|
+
tableName: "app_oauth_refresh_tokens";
|
|
2176
|
+
dataType: "number";
|
|
2177
|
+
columnType: "PgInteger";
|
|
2178
|
+
data: number;
|
|
2179
|
+
driverParam: string | number;
|
|
2180
|
+
notNull: false;
|
|
2181
|
+
hasDefault: false;
|
|
2182
|
+
isPrimaryKey: false;
|
|
2183
|
+
isAutoincrement: false;
|
|
2184
|
+
hasRuntimeDefault: false;
|
|
2185
|
+
enumValues: undefined;
|
|
2186
|
+
baseColumn: never;
|
|
2187
|
+
identity: undefined;
|
|
2188
|
+
generated: undefined;
|
|
2189
|
+
}, {}, {}>;
|
|
2054
2190
|
status: import("drizzle-orm/pg-core").PgColumn<{
|
|
2055
2191
|
name: "status";
|
|
2056
2192
|
tableName: "app_oauth_refresh_tokens";
|
|
@@ -3007,6 +3143,40 @@ export declare const appSessionTokens: import("drizzle-orm/pg-core").PgTableWith
|
|
|
3007
3143
|
identity: undefined;
|
|
3008
3144
|
generated: undefined;
|
|
3009
3145
|
}, {}, {}>;
|
|
3146
|
+
workloadEnvironmentId: import("drizzle-orm/pg-core").PgColumn<{
|
|
3147
|
+
name: "workload_environment_id";
|
|
3148
|
+
tableName: "app_session_tokens";
|
|
3149
|
+
dataType: "string";
|
|
3150
|
+
columnType: "PgText";
|
|
3151
|
+
data: string;
|
|
3152
|
+
driverParam: string;
|
|
3153
|
+
notNull: false;
|
|
3154
|
+
hasDefault: false;
|
|
3155
|
+
isPrimaryKey: false;
|
|
3156
|
+
isAutoincrement: false;
|
|
3157
|
+
hasRuntimeDefault: false;
|
|
3158
|
+
enumValues: [string, ...string[]];
|
|
3159
|
+
baseColumn: never;
|
|
3160
|
+
identity: undefined;
|
|
3161
|
+
generated: undefined;
|
|
3162
|
+
}, {}, {}>;
|
|
3163
|
+
contractGeneration: import("drizzle-orm/pg-core").PgColumn<{
|
|
3164
|
+
name: "contract_generation";
|
|
3165
|
+
tableName: "app_session_tokens";
|
|
3166
|
+
dataType: "number";
|
|
3167
|
+
columnType: "PgInteger";
|
|
3168
|
+
data: number;
|
|
3169
|
+
driverParam: string | number;
|
|
3170
|
+
notNull: false;
|
|
3171
|
+
hasDefault: false;
|
|
3172
|
+
isPrimaryKey: false;
|
|
3173
|
+
isAutoincrement: false;
|
|
3174
|
+
hasRuntimeDefault: false;
|
|
3175
|
+
enumValues: undefined;
|
|
3176
|
+
baseColumn: never;
|
|
3177
|
+
identity: undefined;
|
|
3178
|
+
generated: undefined;
|
|
3179
|
+
}, {}, {}>;
|
|
3010
3180
|
jti: import("drizzle-orm/pg-core").PgColumn<{
|
|
3011
3181
|
name: "jti";
|
|
3012
3182
|
tableName: "app_session_tokens";
|
package/dist/schema.js
CHANGED
|
@@ -155,6 +155,8 @@ export const appInstallations = pgTable("app_installations", {
|
|
|
155
155
|
.notNull()
|
|
156
156
|
.references(() => apps.id, { onDelete: "cascade" }),
|
|
157
157
|
deploymentId: text("deployment_id").notNull(),
|
|
158
|
+
workloadEnvironmentId: text("workload_environment_id"),
|
|
159
|
+
contractGeneration: integer("contract_generation"),
|
|
158
160
|
releaseId: text("release_id")
|
|
159
161
|
.notNull()
|
|
160
162
|
.references(() => appReleases.id, { onDelete: "restrict" }),
|
|
@@ -182,6 +184,10 @@ export const appInstallations = pgTable("app_installations", {
|
|
|
182
184
|
index("idx_app_installations_app").on(table.appId, table.status),
|
|
183
185
|
index("idx_app_installations_deployment").on(table.deploymentId, table.status),
|
|
184
186
|
uniqueIndex("uidx_app_installations_deployment_app").on(table.deploymentId, table.appId),
|
|
187
|
+
uniqueIndex("uidx_app_installations_workload_environment_app")
|
|
188
|
+
.on(table.workloadEnvironmentId, table.appId)
|
|
189
|
+
.where(sql `${table.workloadEnvironmentId} IS NOT NULL`),
|
|
190
|
+
check("app_installations_managed_binding_complete", sql `(${table.workloadEnvironmentId} IS NULL AND ${table.contractGeneration} IS NULL) OR (${table.workloadEnvironmentId} IS NOT NULL AND ${table.contractGeneration} > 0)`),
|
|
185
191
|
// agent-quality: raw-sql reviewed -- owner: apps; identifier is a Drizzle-owned column and the literal prefix is static.
|
|
186
192
|
check("app_installations_namespace_reserved", sql `${table.namespace} LIKE 'app--%'`),
|
|
187
193
|
]);
|
|
@@ -206,6 +212,8 @@ export const appAccessCredentials = pgTable("app_access_credentials", {
|
|
|
206
212
|
.notNull()
|
|
207
213
|
.references(() => appInstallations.id, { onDelete: "cascade" }),
|
|
208
214
|
generation: integer("generation").notNull(),
|
|
215
|
+
workloadEnvironmentId: text("workload_environment_id"),
|
|
216
|
+
contractGeneration: integer("contract_generation"),
|
|
209
217
|
tokenMode: appAccessTokenModeEnum("token_mode").notNull().default("offline"),
|
|
210
218
|
credentialHash: text("credential_hash").notNull(),
|
|
211
219
|
encryptedMetadata: jsonb("encrypted_metadata").$type().notNull(),
|
|
@@ -218,6 +226,7 @@ export const appAccessCredentials = pgTable("app_access_credentials", {
|
|
|
218
226
|
}, (table) => [
|
|
219
227
|
index("idx_app_access_credentials_installation").on(table.installationId, table.status),
|
|
220
228
|
index("idx_app_access_credentials_generation").on(table.installationId, table.tokenMode, table.generation),
|
|
229
|
+
check("app_access_credentials_managed_binding_complete", sql `(${table.workloadEnvironmentId} IS NULL AND ${table.contractGeneration} IS NULL) OR (${table.workloadEnvironmentId} IS NOT NULL AND ${table.contractGeneration} > 0)`),
|
|
221
230
|
]);
|
|
222
231
|
export const appOAuthAuthorizationCodes = pgTable("app_oauth_authorization_codes", {
|
|
223
232
|
id: typeId("app_oauth_authorization_codes"),
|
|
@@ -231,6 +240,8 @@ export const appOAuthAuthorizationCodes = pgTable("app_oauth_authorization_codes
|
|
|
231
240
|
.notNull()
|
|
232
241
|
.references(() => appReleases.id, { onDelete: "cascade" }),
|
|
233
242
|
deploymentId: text("deployment_id").notNull(),
|
|
243
|
+
workloadEnvironmentId: text("workload_environment_id"),
|
|
244
|
+
contractGeneration: integer("contract_generation"),
|
|
234
245
|
codeHash: text("code_hash").notNull(),
|
|
235
246
|
stateHash: text("state_hash").notNull(),
|
|
236
247
|
redirectUri: text("redirect_uri").notNull(),
|
|
@@ -246,6 +257,7 @@ export const appOAuthAuthorizationCodes = pgTable("app_oauth_authorization_codes
|
|
|
246
257
|
}, (table) => [
|
|
247
258
|
uniqueIndex("uidx_app_oauth_codes_hash").on(table.codeHash),
|
|
248
259
|
index("idx_app_oauth_codes_installation").on(table.installationId, table.expiresAt),
|
|
260
|
+
check("app_oauth_codes_managed_binding_complete", sql `(${table.workloadEnvironmentId} IS NULL AND ${table.contractGeneration} IS NULL) OR (${table.workloadEnvironmentId} IS NOT NULL AND ${table.contractGeneration} > 0)`),
|
|
249
261
|
]);
|
|
250
262
|
export const appOAuthRefreshTokens = pgTable("app_oauth_refresh_tokens", {
|
|
251
263
|
id: typeId("app_oauth_refresh_tokens"),
|
|
@@ -254,6 +266,8 @@ export const appOAuthRefreshTokens = pgTable("app_oauth_refresh_tokens", {
|
|
|
254
266
|
.references(() => appInstallations.id, { onDelete: "cascade" }),
|
|
255
267
|
tokenHash: text("token_hash").notNull(),
|
|
256
268
|
generation: integer("generation").notNull(),
|
|
269
|
+
workloadEnvironmentId: text("workload_environment_id"),
|
|
270
|
+
contractGeneration: integer("contract_generation"),
|
|
257
271
|
status: appAccessCredentialStatusEnum("status").notNull().default("active"),
|
|
258
272
|
rotatedFromId: text("rotated_from_id"),
|
|
259
273
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
@@ -262,6 +276,7 @@ export const appOAuthRefreshTokens = pgTable("app_oauth_refresh_tokens", {
|
|
|
262
276
|
}, (table) => [
|
|
263
277
|
uniqueIndex("uidx_app_oauth_refresh_tokens_hash").on(table.tokenHash),
|
|
264
278
|
index("idx_app_oauth_refresh_tokens_installation").on(table.installationId, table.status),
|
|
279
|
+
check("app_oauth_refresh_tokens_managed_binding_complete", sql `(${table.workloadEnvironmentId} IS NULL AND ${table.contractGeneration} IS NULL) OR (${table.workloadEnvironmentId} IS NOT NULL AND ${table.contractGeneration} > 0)`),
|
|
265
280
|
]);
|
|
266
281
|
export const appInstallationSettings = pgTable("app_installation_settings", {
|
|
267
282
|
id: typeId("app_installation_settings"),
|
|
@@ -354,6 +369,8 @@ export const appSessionTokens = pgTable("app_session_tokens", {
|
|
|
354
369
|
.references(() => appInstallations.id, { onDelete: "cascade" }),
|
|
355
370
|
appId: text("app_id").notNull(),
|
|
356
371
|
deploymentId: text("deployment_id").notNull(),
|
|
372
|
+
workloadEnvironmentId: text("workload_environment_id"),
|
|
373
|
+
contractGeneration: integer("contract_generation"),
|
|
357
374
|
jti: text("jti").notNull(),
|
|
358
375
|
viewerId: text("viewer_id").notNull(),
|
|
359
376
|
entityType: text("entity_type"),
|
|
@@ -366,4 +383,5 @@ export const appSessionTokens = pgTable("app_session_tokens", {
|
|
|
366
383
|
}, (table) => [
|
|
367
384
|
uniqueIndex("uidx_app_session_tokens_jti").on(table.jti),
|
|
368
385
|
index("idx_app_session_tokens_installation").on(table.installationId, table.expiresAt),
|
|
386
|
+
check("app_session_tokens_managed_binding_complete", sql `(${table.workloadEnvironmentId} IS NULL AND ${table.contractGeneration} IS NULL) OR (${table.workloadEnvironmentId} IS NOT NULL AND ${table.contractGeneration} > 0)`),
|
|
369
387
|
]);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
2
|
import type { createAppOAuthService } from "./oauth-service.js";
|
|
3
|
+
import type { ManagedAppInstallationAuthority } from "./runtime-port.js";
|
|
3
4
|
import { type AppSessionTokenEntity } from "./session-token.js";
|
|
4
5
|
type AppOAuthService = ReturnType<typeof createAppOAuthService>;
|
|
5
6
|
export interface AppSessionTokenServiceOptions {
|
|
@@ -7,6 +8,7 @@ export interface AppSessionTokenServiceOptions {
|
|
|
7
8
|
secret: string;
|
|
8
9
|
/** The deployment audience the tokens are bound to. */
|
|
9
10
|
deploymentId: string;
|
|
11
|
+
managedInstallation?: ManagedAppInstallationAuthority;
|
|
10
12
|
/** OAuth service supplying the online actor-token-exchange primitive. */
|
|
11
13
|
oauth: AppOAuthService;
|
|
12
14
|
ttlSeconds?: number;
|
|
@@ -20,10 +20,13 @@ 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,
|
|
28
31
|
viewerScopes: input.viewerScopes ?? [],
|
|
29
32
|
entity: input.entity ?? null,
|
|
@@ -37,6 +40,8 @@ export function createAppSessionTokenService(options) {
|
|
|
37
40
|
installationId: installation.id,
|
|
38
41
|
appId: installation.appId,
|
|
39
42
|
deploymentId: installation.deploymentId,
|
|
43
|
+
workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
|
|
44
|
+
contractGeneration: managedBinding?.contractGeneration,
|
|
40
45
|
jti: signed.claims.jti,
|
|
41
46
|
viewerId: input.viewerId,
|
|
42
47
|
entityType: signed.claims.entity?.type ?? null,
|
|
@@ -53,7 +58,7 @@ export function createAppSessionTokenService(options) {
|
|
|
53
58
|
}
|
|
54
59
|
async function exchange(db, input) {
|
|
55
60
|
const verified = verifyAppSessionToken(input.token, options.secret, {
|
|
56
|
-
deploymentId: options.deploymentId,
|
|
61
|
+
deploymentId: options.managedInstallation ? undefined : options.deploymentId,
|
|
57
62
|
audience: input.clientId,
|
|
58
63
|
now,
|
|
59
64
|
});
|
|
@@ -66,7 +71,11 @@ export function createAppSessionTokenService(options) {
|
|
|
66
71
|
const { claims } = verified;
|
|
67
72
|
return db.transaction(async (tx) => {
|
|
68
73
|
const installation = await requireActiveInstallation(tx, claims.installationId);
|
|
69
|
-
|
|
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)) {
|
|
70
79
|
throw new ApiHttpError("Session token installation context changed", {
|
|
71
80
|
status: 401,
|
|
72
81
|
code: "app_session_token_installation_mismatch",
|
|
@@ -88,7 +97,12 @@ export function createAppSessionTokenService(options) {
|
|
|
88
97
|
const consumed = await tx
|
|
89
98
|
.update(appSessionTokens)
|
|
90
99
|
.set({ consumedAt: now(), consumedByActorId: claims.sub })
|
|
91
|
-
.where(and(eq(appSessionTokens.jti, claims.jti), eq(appSessionTokens.installationId, installation.id), eq(appSessionTokens.appId, claims.aud), eq(appSessionTokens.deploymentId, claims.deploymentId),
|
|
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())))
|
|
92
106
|
.returning({ id: appSessionTokens.id });
|
|
93
107
|
if (consumed.length === 0) {
|
|
94
108
|
throw new ApiHttpError("Session token has already been used", {
|
|
@@ -107,6 +121,37 @@ export function createAppSessionTokenService(options) {
|
|
|
107
121
|
}
|
|
108
122
|
return { issue, exchange };
|
|
109
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
|
+
}
|
|
110
155
|
function intersectRequestedScopes(trustedViewerScopes, requestedScopes) {
|
|
111
156
|
const trusted = new Set(trustedViewerScopes);
|
|
112
157
|
return Array.from(new Set(requestedScopes.filter((scope) => trusted.has(scope)))).sort();
|
|
@@ -2,7 +2,7 @@ import { ApiHttpError } from "@voyant-travel/hono";
|
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { appOAuthTokenSchema } from "./contracts.js";
|
|
4
4
|
import { createAppSessionTokenService } from "./session-token-service.js";
|
|
5
|
-
function trackedDatabaseStub() {
|
|
5
|
+
function trackedDatabaseStub(installationOverrides = {}) {
|
|
6
6
|
const installation = {
|
|
7
7
|
id: "inst_1",
|
|
8
8
|
appId: "app_1",
|
|
@@ -10,6 +10,7 @@ function trackedDatabaseStub() {
|
|
|
10
10
|
deploymentId: "dep_1",
|
|
11
11
|
status: "active",
|
|
12
12
|
namespace: "app--one",
|
|
13
|
+
...installationOverrides,
|
|
13
14
|
};
|
|
14
15
|
const consume = vi.fn().mockResolvedValue([{ id: "session_1" }]);
|
|
15
16
|
const db = {
|
|
@@ -154,4 +155,33 @@ describe("app session token service", () => {
|
|
|
154
155
|
await expect(service.exchange(db, input)).resolves.toEqual({ accessToken: "online" });
|
|
155
156
|
expect(consume).toHaveBeenCalledOnce();
|
|
156
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
|
+
});
|
|
157
187
|
});
|
package/dist/session-token.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ 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;
|
|
24
26
|
/** Host-authenticated viewer permissions captured at issuance. */
|
|
@@ -35,6 +37,8 @@ export interface AppSessionTokenClaims {
|
|
|
35
37
|
sub: string;
|
|
36
38
|
installationId: string;
|
|
37
39
|
deploymentId: string;
|
|
40
|
+
workloadEnvironmentId?: string;
|
|
41
|
+
contractGeneration?: number;
|
|
38
42
|
entity: AppSessionTokenEntity | null;
|
|
39
43
|
slot: string | null;
|
|
40
44
|
/** Host-authenticated viewer permissions; never supplied by the app backend. */
|
|
@@ -67,6 +71,9 @@ export interface VerifyAppSessionTokenExpectations {
|
|
|
67
71
|
audience?: string;
|
|
68
72
|
/** Reject unless `deploymentId` equals this deployment. */
|
|
69
73
|
deploymentId?: string;
|
|
74
|
+
/** Managed workload-environment and contract binding; both are required together. */
|
|
75
|
+
workloadEnvironmentId?: string;
|
|
76
|
+
contractGeneration?: number;
|
|
70
77
|
now?: () => Date;
|
|
71
78
|
}
|
|
72
79
|
export type AppSessionTokenVerifyResult = {
|
|
@@ -76,7 +83,7 @@ export type AppSessionTokenVerifyResult = {
|
|
|
76
83
|
ok: false;
|
|
77
84
|
reason: AppSessionTokenRejectReason;
|
|
78
85
|
};
|
|
79
|
-
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";
|
|
80
87
|
/**
|
|
81
88
|
* Verify a session token's signature, issuer, expiry, and (when supplied)
|
|
82
89
|
* audience and deployment binding. Returns the decoded claims on success or a
|
package/dist/session-token.js
CHANGED
|
Binary file
|
|
@@ -10,6 +10,11 @@ const context = {
|
|
|
10
10
|
entity: { type: "booking", id: "book_1" },
|
|
11
11
|
slot: "booking.details.header",
|
|
12
12
|
};
|
|
13
|
+
const managedContext = {
|
|
14
|
+
...context,
|
|
15
|
+
workloadEnvironmentId: "workload_environment_1",
|
|
16
|
+
contractGeneration: 3,
|
|
17
|
+
};
|
|
13
18
|
const at = (iso) => () => new Date(iso);
|
|
14
19
|
describe("app session token", () => {
|
|
15
20
|
it("round-trips claims, binds context, and carries a unique id", () => {
|
|
@@ -65,6 +70,47 @@ describe("app session token", () => {
|
|
|
65
70
|
});
|
|
66
71
|
expect(result).toEqual({ ok: false, reason: "deployment_mismatch" });
|
|
67
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
|
+
});
|
|
68
114
|
it("rejects a tampered signature", () => {
|
|
69
115
|
const signed = signAppSessionToken(context, secret, { now: at("2026-07-17T10:00:00Z") });
|
|
70
116
|
const tampered = `${signed.token.slice(0, -2)}xy`;
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyant-travel/apps",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -126,13 +126,13 @@
|
|
|
126
126
|
"drizzle-orm": "^0.45.2",
|
|
127
127
|
"hono": "^4.12.27",
|
|
128
128
|
"zod": "^4.4.3",
|
|
129
|
-
"@voyant-travel/admin": "^0.127.0",
|
|
130
129
|
"@voyant-travel/admin-extension-sdk": "^0.2.0",
|
|
131
|
-
"@voyant-travel/core": "^0.127.
|
|
130
|
+
"@voyant-travel/core": "^0.127.1",
|
|
131
|
+
"@voyant-travel/admin": "^0.127.0",
|
|
132
132
|
"@voyant-travel/custom-fields": "^0.2.4",
|
|
133
133
|
"@voyant-travel/finance-contracts": "^0.107.0",
|
|
134
134
|
"@voyant-travel/db": "^0.114.13",
|
|
135
|
-
"@voyant-travel/hono": "^0.129.
|
|
135
|
+
"@voyant-travel/hono": "^0.129.2",
|
|
136
136
|
"@voyant-travel/types": "^0.109.4",
|
|
137
137
|
"@voyant-travel/webhook-delivery": "^0.4.2"
|
|
138
138
|
},
|