@voyant-travel/apps 0.8.0 → 0.9.1

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/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
- if (installation.appId !== claims.aud || installation.deploymentId !== claims.deploymentId) {
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), eq(appSessionTokens.viewerId, claims.sub), isNull(appSessionTokens.consumedAt), gt(appSessionTokens.expiresAt, now())))
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
  });
@@ -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
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.8.0",
3
+ "version": "0.9.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -128,13 +128,13 @@
128
128
  "zod": "^4.4.3",
129
129
  "@voyant-travel/admin": "^0.127.0",
130
130
  "@voyant-travel/admin-extension-sdk": "^0.2.0",
131
- "@voyant-travel/core": "^0.127.0",
132
- "@voyant-travel/custom-fields": "^0.2.4",
131
+ "@voyant-travel/core": "^0.128.0",
132
+ "@voyant-travel/custom-fields": "^0.2.5",
133
133
  "@voyant-travel/finance-contracts": "^0.107.0",
134
- "@voyant-travel/db": "^0.114.13",
135
- "@voyant-travel/hono": "^0.129.0",
134
+ "@voyant-travel/db": "^0.114.14",
135
+ "@voyant-travel/hono": "^0.130.0",
136
136
  "@voyant-travel/types": "^0.109.4",
137
- "@voyant-travel/webhook-delivery": "^0.4.2"
137
+ "@voyant-travel/webhook-delivery": "^0.4.3"
138
138
  },
139
139
  "devDependencies": {
140
140
  "@types/node": "^25.5.2",