@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.
@@ -2,12 +2,14 @@ import { ApiHttpError } from "@voyant-travel/hono";
2
2
  import { and, eq } from "drizzle-orm";
3
3
  import { computeAppConsent } from "./consent.js";
4
4
  import { APP_ACCESS_TOKEN_PREFIX, APP_AUTH_CODE_PREFIX, APP_REFRESH_TOKEN_PREFIX, constantTimeEqual, randomToken, sha256Hex, verifyPkceS256, } from "./oauth-crypto.js";
5
- import { appAccessCredentials, appAuditEvents, appCredentials, appGrants, appInstallations, appOAuthAuthorizationCodes, appOAuthRefreshTokens, appRedirectUris, appReleases, apps, } from "./schema.js";
5
+ import { assertActiveInstallation, assertManagedBinding, assertManagedInstallationAuthority, assertTokenClient, ensureAuthorizedInstallation, grantedScopes, isInstallationUsable, managedBindingMatches, requireInstallation, selectInstallation, } from "./oauth-installation.js";
6
+ import { appAccessCredentials, appAuditEvents, appCredentials, appInstallations, appOAuthAuthorizationCodes, appOAuthRefreshTokens, appRedirectUris, appReleases, } from "./schema.js";
6
7
  const CODE_TTL_MS = 5 * 60 * 1000;
7
8
  const OFFLINE_ACCESS_TTL_MS = 60 * 60 * 1000;
8
9
  const ONLINE_ACCESS_TTL_MS = 10 * 60 * 1000;
9
10
  const REFRESH_TTL_MS = 90 * 24 * 60 * 60 * 1000;
10
11
  export function createAppOAuthService(options) {
12
+ assertManagedInstallationAuthority(options.managedInstallation);
11
13
  const now = () => options.now?.() ?? new Date();
12
14
  async function authorize(db, input) {
13
15
  assertState(input.state);
@@ -22,10 +24,12 @@ export function createAppOAuthService(options) {
22
24
  operatorGrantedScopes: input.operatorGrantedScopes,
23
25
  grantedOptionalScopes: input.grantedOptionalScopes,
24
26
  });
27
+ const managedBinding = await resolveManagedBinding(input.appId, input.releaseId);
25
28
  const installation = await ensureAuthorizedInstallation(db, {
26
29
  appId: input.appId,
27
30
  releaseId: input.releaseId,
28
31
  deploymentId: options.deploymentId,
32
+ managedBinding,
29
33
  actorId: input.actorId,
30
34
  grantedScopes: consent.grantedScopes,
31
35
  deniedOptionalScopes: consent.deniedOptionalScopes,
@@ -37,6 +41,8 @@ export function createAppOAuthService(options) {
37
41
  installationId: installation.id,
38
42
  releaseId: input.releaseId,
39
43
  deploymentId: options.deploymentId,
44
+ workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
45
+ contractGeneration: managedBinding?.contractGeneration,
40
46
  codeHash: sha256Hex(code),
41
47
  stateHash: sha256Hex(input.state),
42
48
  redirectUri: input.redirectUri,
@@ -73,8 +79,17 @@ export function createAppOAuthService(options) {
73
79
  if (!credential || (credential.expiresAt && credential.expiresAt <= now()))
74
80
  return null;
75
81
  const installation = await selectInstallation(db, credential.installationId);
76
- if (!installation || !isInstallationUsable(installation, credential.generation))
82
+ if (!installation)
83
+ return null;
84
+ const managedBinding = await resolveManagedBindingOrNull(installation.appId, installation.releaseId);
85
+ if (options.managedInstallation && !managedBinding)
86
+ return null;
87
+ const effectiveManagedBinding = managedBinding ?? undefined;
88
+ if (!isInstallationUsable(installation, credential.generation) ||
89
+ !managedBindingMatches(installation, effectiveManagedBinding) ||
90
+ !managedBindingMatches(credential, effectiveManagedBinding)) {
77
91
  return null;
92
+ }
78
93
  // Online tokens resolve to the scope set minted at exchange (viewer/context
79
94
  // intersection); recomputing from grants would silently widen them. Offline
80
95
  // tokens track live grants so revoking a scope applies immediately. A
@@ -97,6 +112,12 @@ export function createAppOAuthService(options) {
97
112
  appInstallationId: installation.id,
98
113
  appReleaseId: installation.releaseId,
99
114
  appCredentialGeneration: credential.generation,
115
+ ...(effectiveManagedBinding
116
+ ? {
117
+ appWorkloadEnvironmentId: effectiveManagedBinding.workloadEnvironmentId,
118
+ appContractGeneration: effectiveManagedBinding.contractGeneration,
119
+ }
120
+ : {}),
100
121
  appTokenMode: credential.tokenMode,
101
122
  appViewerId: credential.viewerId ?? undefined,
102
123
  ...(appContextConstraint ? { appContextConstraint } : {}),
@@ -106,6 +127,8 @@ export function createAppOAuthService(options) {
106
127
  async function revokeInstallationCredentials(db, installationId, actorId) {
107
128
  return db.transaction(async (tx) => {
108
129
  const installation = await requireInstallation(tx, installationId);
130
+ const managedBinding = await resolveManagedBinding(installation.appId, installation.releaseId);
131
+ assertManagedBinding(installation, managedBinding);
109
132
  const generation = installation.credentialGeneration + 1;
110
133
  await tx
111
134
  .update(appInstallations)
@@ -142,13 +165,19 @@ export function createAppOAuthService(options) {
142
165
  if (!verifyPkceS256(input.codeVerifier, code.codeChallenge)) {
143
166
  throw oauthError("invalid_grant", "PKCE verifier does not match the authorization code");
144
167
  }
168
+ if (!options.managedInstallation && code.deploymentId !== options.deploymentId) {
169
+ throw oauthError("invalid_grant", "Authorization code belongs to a different runtime");
170
+ }
171
+ const managedBinding = await resolveManagedBinding(code.appId, code.releaseId);
172
+ assertManagedBinding(code, managedBinding);
145
173
  await tx
146
174
  .update(appOAuthAuthorizationCodes)
147
175
  .set({ consumedAt: now() })
148
176
  .where(eq(appOAuthAuthorizationCodes.id, code.id));
149
177
  const installation = await requireInstallation(tx, code.installationId);
150
178
  assertActiveInstallation(installation);
151
- const tokens = await mintTokens(tx, installation, code.actorId, null, code.grantedScopes);
179
+ assertManagedBinding(installation, managedBinding);
180
+ const tokens = await mintTokens(tx, installation, code.actorId, null, code.grantedScopes, managedBinding);
152
181
  await audit(tx, installation, code.actorId, "token", "oauth.code.exchanged", {});
153
182
  return tokens;
154
183
  });
@@ -165,8 +194,11 @@ export function createAppOAuthService(options) {
165
194
  throw oauthError("invalid_grant", "Refresh token is invalid");
166
195
  }
167
196
  const installation = await requireInstallation(tx, tokenRow.installationId);
197
+ const managedBinding = await resolveManagedBinding(installation.appId, installation.releaseId);
168
198
  assertTokenClient(installation, input.clientId);
169
199
  assertActiveInstallation(installation);
200
+ assertManagedBinding(tokenRow, managedBinding);
201
+ assertManagedBinding(installation, managedBinding);
170
202
  if (installation.credentialGeneration !== tokenRow.generation) {
171
203
  throw oauthError("invalid_grant", "Refresh token generation was revoked");
172
204
  }
@@ -174,7 +206,7 @@ export function createAppOAuthService(options) {
174
206
  .update(appOAuthRefreshTokens)
175
207
  .set({ status: "inactive", revokedAt: now() })
176
208
  .where(eq(appOAuthRefreshTokens.id, tokenRow.id));
177
- const tokens = await mintTokens(tx, installation, "app", null, await grantedScopes(tx, installation.id), {
209
+ const tokens = await mintTokens(tx, installation, "app", null, await grantedScopes(tx, installation.id), managedBinding, {
178
210
  rotatedFromId: tokenRow.id,
179
211
  });
180
212
  await audit(tx, installation, "app", "token", "oauth.refresh.rotated", {
@@ -185,8 +217,10 @@ export function createAppOAuthService(options) {
185
217
  }
186
218
  async function exchangeActorToken(db, input) {
187
219
  const installation = await requireInstallation(db, input.installationId);
220
+ const managedBinding = await resolveManagedBinding(installation.appId, installation.releaseId);
188
221
  assertTokenClient(installation, input.clientId);
189
222
  assertActiveInstallation(installation);
223
+ assertManagedBinding(installation, managedBinding);
190
224
  const grants = await grantedScopes(db, installation.id);
191
225
  const contextual = input.contextualScopes ?? grants;
192
226
  const scopes = intersectAppTokenScopes(grants, input.viewerScopes, contextual);
@@ -195,6 +229,8 @@ export function createAppOAuthService(options) {
195
229
  await db.insert(appAccessCredentials).values({
196
230
  installationId: installation.id,
197
231
  generation: installation.credentialGeneration,
232
+ workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
233
+ contractGeneration: managedBinding?.contractGeneration,
198
234
  tokenMode: "online",
199
235
  credentialHash: sha256Hex(accessToken),
200
236
  // Online tokens are intentionally narrowed; the resolver must honor the
@@ -214,7 +250,7 @@ export function createAppOAuthService(options) {
214
250
  });
215
251
  return tokenResponse(accessToken, null, expiresAt, scopes, "online");
216
252
  }
217
- async function mintTokens(db, installation, actorId, viewerId, scopes, refreshOptions = {}) {
253
+ async function mintTokens(db, installation, actorId, viewerId, scopes, managedBinding, refreshOptions = {}) {
218
254
  const accessToken = randomToken(APP_ACCESS_TOKEN_PREFIX);
219
255
  const refreshToken = randomToken(APP_REFRESH_TOKEN_PREFIX);
220
256
  const accessExpiresAt = new Date(now().getTime() + OFFLINE_ACCESS_TTL_MS);
@@ -223,6 +259,8 @@ export function createAppOAuthService(options) {
223
259
  await db.insert(appAccessCredentials).values({
224
260
  installationId: installation.id,
225
261
  generation,
262
+ workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
263
+ contractGeneration: managedBinding?.contractGeneration,
226
264
  tokenMode: "offline",
227
265
  credentialHash: sha256Hex(accessToken),
228
266
  encryptedMetadata: { scopeCount: scopes.length },
@@ -235,6 +273,8 @@ export function createAppOAuthService(options) {
235
273
  installationId: installation.id,
236
274
  tokenHash: sha256Hex(refreshToken),
237
275
  generation,
276
+ workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
277
+ contractGeneration: managedBinding?.contractGeneration,
238
278
  rotatedFromId: refreshOptions.rotatedFromId,
239
279
  expiresAt: refreshExpiresAt,
240
280
  });
@@ -243,6 +283,31 @@ export function createAppOAuthService(options) {
243
283
  function isExpired(expiresAt) {
244
284
  return Boolean(expiresAt && expiresAt <= now());
245
285
  }
286
+ async function resolveManagedBinding(appId, releaseId) {
287
+ if (!options.managedInstallation)
288
+ return undefined;
289
+ const contract = await options.managedInstallation.resolveInstallationContract({
290
+ appId,
291
+ releaseId,
292
+ });
293
+ if (!contract ||
294
+ !Number.isSafeInteger(contract.contractGeneration) ||
295
+ contract.contractGeneration <= 0) {
296
+ throw oauthError("invalid_grant", "Managed app installation contract is unavailable");
297
+ }
298
+ return {
299
+ workloadEnvironmentId: options.managedInstallation.workloadEnvironmentId,
300
+ contractGeneration: contract.contractGeneration,
301
+ };
302
+ }
303
+ async function resolveManagedBindingOrNull(appId, releaseId) {
304
+ try {
305
+ return await resolveManagedBinding(appId, releaseId);
306
+ }
307
+ catch {
308
+ return null;
309
+ }
310
+ }
246
311
  }
247
312
  function tokenResponse(accessToken, refreshToken, expiresAt, scopes, tokenMode) {
248
313
  return {
@@ -295,94 +360,6 @@ async function requireExactRedirectUri(db, appId, redirectUri) {
295
360
  if (!row)
296
361
  throw oauthError("invalid_request", "Redirect URI is not registered for this app");
297
362
  }
298
- async function ensureAuthorizedInstallation(db, input) {
299
- return db.transaction(async (tx) => {
300
- const [app] = await tx.select().from(apps).where(eq(apps.id, input.appId)).limit(1);
301
- if (!app)
302
- throw oauthError("invalid_request", "App registration not found", 404);
303
- const [existing] = await tx
304
- .select()
305
- .from(appInstallations)
306
- .where(and(eq(appInstallations.deploymentId, input.deploymentId), eq(appInstallations.appId, input.appId)))
307
- .limit(1);
308
- const installation = existing ??
309
- (await tx
310
- .insert(appInstallations)
311
- .values({
312
- appId: input.appId,
313
- deploymentId: input.deploymentId,
314
- releaseId: input.releaseId,
315
- status: "active",
316
- namespace: app.platformNamespace,
317
- installedBy: input.actorId,
318
- authorizedAt: new Date(),
319
- activatedAt: new Date(),
320
- })
321
- .returning())[0];
322
- if (!installation)
323
- throw oauthError("server_error", "Could not create installation", 500);
324
- for (const scope of input.grantedScopes) {
325
- await tx
326
- .insert(appGrants)
327
- .values({
328
- installationId: installation.id,
329
- scope,
330
- status: "granted",
331
- optional: false,
332
- grantedAt: new Date(),
333
- })
334
- .onConflictDoUpdate({
335
- target: [appGrants.installationId, appGrants.scope],
336
- set: { status: "granted", grantedAt: new Date(), revokedAt: null },
337
- });
338
- }
339
- for (const scope of input.deniedOptionalScopes) {
340
- await tx
341
- .insert(appGrants)
342
- .values({ installationId: installation.id, scope, status: "optional", optional: true })
343
- .onConflictDoUpdate({
344
- target: [appGrants.installationId, appGrants.scope],
345
- set: { status: "optional", optional: true },
346
- });
347
- }
348
- return installation;
349
- });
350
- }
351
- async function requireInstallation(db, installationId) {
352
- const installation = await selectInstallation(db, installationId);
353
- if (!installation)
354
- throw oauthError("invalid_grant", "App installation not found", 404);
355
- return installation;
356
- }
357
- async function selectInstallation(db, installationId) {
358
- const [installation] = await db
359
- .select()
360
- .from(appInstallations)
361
- .where(eq(appInstallations.id, installationId))
362
- .limit(1);
363
- return installation ?? null;
364
- }
365
- async function grantedScopes(db, installationId) {
366
- const rows = await db
367
- .select({ scope: appGrants.scope })
368
- .from(appGrants)
369
- .where(and(eq(appGrants.installationId, installationId), eq(appGrants.status, "granted")))
370
- .orderBy(appGrants.scope);
371
- return rows.map((row) => row.scope);
372
- }
373
- function assertActiveInstallation(installation) {
374
- if (installation.status !== "active") {
375
- throw oauthError("invalid_grant", "App installation is not active");
376
- }
377
- }
378
- function isInstallationUsable(installation, generation) {
379
- return installation?.status === "active" && installation.credentialGeneration === generation;
380
- }
381
- function assertTokenClient(installation, clientId) {
382
- if (installation.appId !== clientId) {
383
- throw oauthError("invalid_grant", "Token belongs to a different app");
384
- }
385
- }
386
363
  export function intersectAppTokenScopes(...sets) {
387
364
  const [first = [], ...rest] = sets;
388
365
  return first.filter((scope) => rest.every((set) => set.includes(scope))).sort();
@@ -1,6 +1,24 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { createAppOAuthService, intersectAppTokenScopes, readStoredAppContextConstraint, readStoredScopes, } from "./oauth-service.js";
3
- import { appCredentials, appReleases } from "./schema.js";
3
+ import { appAccessCredentials, appCredentials, appGrants, appInstallations, appReleases, } from "./schema.js";
4
+ function accessTokenDatabase(installation, credential) {
5
+ return Object.assign(Object.create(null), {
6
+ select: () => ({
7
+ from: (table) => {
8
+ if (table === appAccessCredentials) {
9
+ return { where: () => ({ limit: async () => [credential] }) };
10
+ }
11
+ if (table === appInstallations) {
12
+ return { where: () => ({ limit: async () => [installation] }) };
13
+ }
14
+ if (table === appGrants) {
15
+ return { where: () => ({ orderBy: async () => [] }) };
16
+ }
17
+ return { where: () => ({ limit: async () => [] }) };
18
+ },
19
+ }),
20
+ });
21
+ }
4
22
  describe("app OAuth online token scope intersection", () => {
5
23
  it("never exceeds either app grants, viewer grants, or contextual restrictions", () => {
6
24
  expect(intersectAppTokenScopes(["bookings:read", "invoices:read"], ["bookings:read", "customers:read"], ["bookings:read", "invoices:read"])).toEqual(["bookings:read"]);
@@ -95,3 +113,67 @@ describe("managed client authentication", () => {
95
113
  })).rejects.toMatchObject({ status: 401, code: "invalid_client" });
96
114
  });
97
115
  });
116
+ describe("managed installation token binding", () => {
117
+ const baseInstallation = {
118
+ id: "inst_1",
119
+ appId: "app_1",
120
+ releaseId: "release_1",
121
+ deploymentId: "deployment_revision_1",
122
+ workloadEnvironmentId: "workload_environment_1",
123
+ contractGeneration: 4,
124
+ credentialGeneration: 2,
125
+ status: "active",
126
+ };
127
+ const baseCredential = {
128
+ id: "credential_1",
129
+ installationId: "inst_1",
130
+ workloadEnvironmentId: "workload_environment_1",
131
+ contractGeneration: 4,
132
+ generation: 2,
133
+ tokenMode: "offline",
134
+ encryptedMetadata: {},
135
+ status: "active",
136
+ expiresAt: new Date("2026-07-19T00:00:00Z"),
137
+ viewerId: null,
138
+ };
139
+ it("resolves the per-app generation into the runtime auth context", async () => {
140
+ const generations = new Map([
141
+ ["app_1", 4],
142
+ ["app_2", 11],
143
+ ]);
144
+ const service = createAppOAuthService({
145
+ accessCatalog: { resources: [], presets: [] },
146
+ deploymentId: "deployment_revision_2",
147
+ managedInstallation: {
148
+ workloadEnvironmentId: "workload_environment_1",
149
+ resolveInstallationContract: async ({ appId }) => ({
150
+ contractGeneration: generations.get(appId) ?? 0,
151
+ }),
152
+ },
153
+ now: () => new Date("2026-07-18T12:00:00Z"),
154
+ });
155
+ await expect(service.resolveAccessToken(accessTokenDatabase(baseInstallation, baseCredential), "vapp_fixture")).resolves.toMatchObject({
156
+ appWorkloadEnvironmentId: "workload_environment_1",
157
+ appContractGeneration: 4,
158
+ });
159
+ });
160
+ it.each([
161
+ ["workload_environment_other", 4],
162
+ ["workload_environment_1", 3],
163
+ ])("rejects credential binding workload=%s generation=%s", async (workloadEnvironmentId, contractGeneration) => {
164
+ const service = createAppOAuthService({
165
+ accessCatalog: { resources: [], presets: [] },
166
+ deploymentId: "deployment_revision_2",
167
+ managedInstallation: {
168
+ workloadEnvironmentId: "workload_environment_1",
169
+ resolveInstallationContract: async () => ({ contractGeneration: 4 }),
170
+ },
171
+ now: () => new Date("2026-07-18T12:00:00Z"),
172
+ });
173
+ await expect(service.resolveAccessToken(accessTokenDatabase(baseInstallation, {
174
+ ...baseCredential,
175
+ workloadEnvironmentId,
176
+ contractGeneration,
177
+ }), "vapp_fixture")).resolves.toBeNull();
178
+ });
179
+ });
package/dist/routes.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { EventBus } from "@voyant-travel/core/events";
3
3
  import type { createCustomFieldsService } from "@voyant-travel/custom-fields";
4
4
  import { type AccessCatalog } from "@voyant-travel/types/api-keys";
5
5
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
6
+ import type { ManagedAppInstallationAuthority } from "./runtime-port.js";
6
7
  import { type AppsServiceOptions } from "./service.js";
7
8
  type CustomFieldsService = ReturnType<typeof createCustomFieldsService>;
8
9
  type Env = {
@@ -15,6 +16,7 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
15
16
  oauth?: {
16
17
  accessCatalog: AccessCatalog;
17
18
  deploymentId: string;
19
+ managedInstallation?: ManagedAppInstallationAuthority;
18
20
  clientAuthentication?: "optional" | "required";
19
21
  };
20
22
  /**
@@ -24,6 +26,7 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
24
26
  */
25
27
  sessionToken?: {
26
28
  secret: string;
29
+ managedInstallation?: ManagedAppInstallationAuthority;
27
30
  ttlSeconds?: number;
28
31
  };
29
32
  /**
package/dist/routes.js CHANGED
@@ -18,6 +18,7 @@ export function createAppsAdminRoutes(options = {}) {
18
18
  const service = createAppsService(options);
19
19
  const installations = createAppInstallationService({
20
20
  deploymentId: options.oauth?.deploymentId ?? options.deploymentId,
21
+ managedInstallation: options.oauth?.managedInstallation,
21
22
  platformApiVersion: options.platformApiVersion,
22
23
  eventBus: options.eventBus,
23
24
  customFields: options.customFields,
@@ -29,6 +30,7 @@ export function createAppsAdminRoutes(options = {}) {
29
30
  secret: options.sessionToken.secret,
30
31
  ttlSeconds: options.sessionToken.ttlSeconds,
31
32
  deploymentId: options.oauth.deploymentId,
33
+ managedInstallation: options.sessionToken.managedInstallation ?? options.oauth.managedInstallation,
32
34
  oauth,
33
35
  })
34
36
  : null;
@@ -5,5 +5,9 @@ export interface AppsRuntimeContributorHost {
5
5
  id: string;
6
6
  }): boolean;
7
7
  }
8
- /** Package-owned, provider-neutral managed-auth configuration. */
8
+ /**
9
+ * Managed installation contracts are host authority and cannot be reconstructed
10
+ * from scalar environment values. Hosts contribute `apps.managed-auth`
11
+ * explicitly; self-hosted runtimes keep the port absent.
12
+ */
9
13
  export declare function createAppsRuntimePortContribution(host: AppsRuntimeContributorHost): Readonly<Record<string, unknown>>;
@@ -1,26 +1,11 @@
1
1
  import { appsManagedAuthRuntimePort } from "./runtime-port.js";
2
- function readString(host, key) {
3
- const value = host.primitives.config.read(undefined, key);
4
- if (typeof value !== "string")
5
- return undefined;
6
- const trimmed = value.trim();
7
- return trimmed || undefined;
8
- }
9
- /** Package-owned, provider-neutral managed-auth configuration. */
2
+ /**
3
+ * Managed installation contracts are host authority and cannot be reconstructed
4
+ * from scalar environment values. Hosts contribute `apps.managed-auth`
5
+ * explicitly; self-hosted runtimes keep the port absent.
6
+ */
10
7
  export function createAppsRuntimePortContribution(host) {
11
8
  if (host.hasRuntimePort?.(appsManagedAuthRuntimePort))
12
9
  return {};
13
- const runtimeAudience = readString(host, "VOYANT_APP_RUNTIME_AUDIENCE");
14
- const sessionTokenSigningSecret = readString(host, "VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET");
15
- if (!runtimeAudience || !sessionTokenSigningSecret)
16
- return {};
17
- const ttlInput = readString(host, "VOYANT_APP_SESSION_TOKEN_TTL_SECONDS");
18
- const sessionTokenTtlSeconds = ttlInput === undefined ? undefined : Number(ttlInput);
19
- const runtime = {
20
- runtimeAudience,
21
- sessionTokenSigningSecret,
22
- ...(sessionTokenTtlSeconds === undefined ? {} : { sessionTokenTtlSeconds }),
23
- };
24
- appsManagedAuthRuntimePort.test(runtime);
25
- return { [appsManagedAuthRuntimePort.id]: runtime };
10
+ return {};
26
11
  }
@@ -10,23 +10,12 @@ function host(values) {
10
10
  };
11
11
  }
12
12
  describe("createAppsRuntimePortContribution", () => {
13
- it("stays off unless both managed-auth inputs are present", () => {
13
+ it("stays off because scalar environment values cannot authorize managed installations", () => {
14
14
  expect(createAppsRuntimePortContribution(host({}))).toEqual({});
15
- expect(createAppsRuntimePortContribution(host({ VOYANT_APP_RUNTIME_AUDIENCE: "deployment-1" }))).toEqual({});
16
- });
17
- it("contributes validated provider-neutral managed-auth configuration", () => {
18
- const contribution = createAppsRuntimePortContribution(host({
19
- VOYANT_APP_RUNTIME_AUDIENCE: " deployment-1 ",
15
+ expect(createAppsRuntimePortContribution(host({
16
+ VOYANT_APP_RUNTIME_AUDIENCE: "deployment-1",
20
17
  VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "s".repeat(32),
21
- VOYANT_APP_SESSION_TOKEN_TTL_SECONDS: "180",
22
- }));
23
- expect(contribution).toEqual({
24
- [appsManagedAuthRuntimePort.id]: {
25
- runtimeAudience: "deployment-1",
26
- sessionTokenSigningSecret: "s".repeat(32),
27
- sessionTokenTtlSeconds: 180,
28
- },
29
- });
18
+ }))).toEqual({});
30
19
  });
31
20
  it("does not replace an explicitly host-provided managed-auth port", () => {
32
21
  const contribution = createAppsRuntimePortContribution({
@@ -38,15 +27,29 @@ describe("createAppsRuntimePortContribution", () => {
38
27
  });
39
28
  expect(contribution).toEqual({});
40
29
  });
41
- it("rejects weak signing material and long-lived session tokens", () => {
42
- expect(() => createAppsRuntimePortContribution(host({
43
- VOYANT_APP_RUNTIME_AUDIENCE: "deployment-1",
44
- VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "test-secret",
45
- }))).toThrow(/at least 32 characters/);
46
- expect(() => createAppsRuntimePortContribution(host({
47
- VOYANT_APP_RUNTIME_AUDIENCE: "deployment-1",
48
- VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "s".repeat(32),
49
- VOYANT_APP_SESSION_TOKEN_TTL_SECONDS: "301",
50
- }))).toThrow(/1 through 300/);
30
+ it("resolves independent per-app generations within one workload environment", async () => {
31
+ const generations = new Map([
32
+ ["app_1", 2],
33
+ ["app_2", 9],
34
+ ]);
35
+ const runtime = {
36
+ runtimeAudience: "runtime-audience",
37
+ installationAuthority: {
38
+ workloadEnvironmentId: "workload_environment_1",
39
+ resolveInstallationContract: async ({ appId }) => ({
40
+ contractGeneration: generations.get(appId) ?? 0,
41
+ }),
42
+ },
43
+ sessionTokenSigningSecret: "s".repeat(32),
44
+ };
45
+ expect(() => appsManagedAuthRuntimePort.test(runtime)).not.toThrow();
46
+ await expect(runtime.installationAuthority.resolveInstallationContract({
47
+ appId: "app_1",
48
+ releaseId: "release_1",
49
+ })).resolves.toEqual({ contractGeneration: 2 });
50
+ await expect(runtime.installationAuthority.resolveInstallationContract({
51
+ appId: "app_2",
52
+ releaseId: "release_2",
53
+ })).resolves.toEqual({ contractGeneration: 9 });
51
54
  });
52
55
  });
@@ -1,6 +1,27 @@
1
+ /** Persisted host contract for one managed app installation. */
2
+ export interface ManagedAppInstallationBinding {
3
+ workloadEnvironmentId: string;
4
+ contractGeneration: number;
5
+ }
6
+ export interface ManagedAppInstallationContractInput {
7
+ appId: string;
8
+ releaseId: string;
9
+ }
10
+ export interface ManagedAppInstallationContract {
11
+ /** Per-app monotonic generation. Advancing it invalidates prior app credentials. */
12
+ contractGeneration: number;
13
+ }
14
+ export interface ManagedAppInstallationAuthority {
15
+ /** Stable opaque workload-environment identity across runtime rollouts. */
16
+ workloadEnvironmentId: string;
17
+ /** Resolve the current admitted contract for this specific app release. */
18
+ resolveInstallationContract(input: ManagedAppInstallationContractInput): Promise<ManagedAppInstallationContract | null>;
19
+ }
1
20
  export interface AppsManagedAuthRuntime {
2
21
  /** Stable audience shared by authorization, installations, and session tokens. */
3
22
  runtimeAudience: string;
23
+ /** Provider-neutral managed installation authority supplied by the host. */
24
+ installationAuthority: ManagedAppInstallationAuthority;
4
25
  /** Host-owned HMAC material for short-lived extension session tokens. */
5
26
  sessionTokenSigningSecret: string;
6
27
  sessionTokenTtlSeconds?: number;
@@ -8,6 +8,12 @@ export const appsManagedAuthRuntimePort = definePort({
8
8
  if (!runtime.runtimeAudience?.trim()) {
9
9
  throw new TypeError("apps.managed-auth runtimeAudience must be a non-empty string.");
10
10
  }
11
+ if (!runtime.installationAuthority?.workloadEnvironmentId?.trim()) {
12
+ throw new TypeError("apps.managed-auth installationAuthority.workloadEnvironmentId must be a non-empty string.");
13
+ }
14
+ if (typeof runtime.installationAuthority.resolveInstallationContract !== "function") {
15
+ throw new TypeError("apps.managed-auth installationAuthority.resolveInstallationContract must be a function.");
16
+ }
11
17
  if (typeof runtime.sessionTokenSigningSecret !== "string" ||
12
18
  runtime.sessionTokenSigningSecret.length < 32) {
13
19
  throw new TypeError("apps.managed-auth sessionTokenSigningSecret must contain at least 32 characters.");