@voyant-travel/apps 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/access-boundary.d.ts +2 -0
  2. package/dist/api-runtime.d.ts +23 -11
  3. package/dist/api-runtime.js +40 -1
  4. package/dist/api-runtime.test.d.ts +1 -0
  5. package/dist/api-runtime.test.js +58 -0
  6. package/dist/app-api-contracts.d.ts +58 -8
  7. package/dist/app-api-contracts.js +102 -9
  8. package/dist/app-api-finance-routes.test.js +482 -2
  9. package/dist/app-api-routes.d.ts +6 -5
  10. package/dist/app-api-routes.js +131 -22
  11. package/dist/app-api-service.d.ts +33 -1
  12. package/dist/app-api-service.js +162 -0
  13. package/dist/compiler.test.js +7 -0
  14. package/dist/consent.d.ts +1 -0
  15. package/dist/consent.js +2 -2
  16. package/dist/contracts.d.ts +2 -26
  17. package/dist/contracts.js +4 -11
  18. package/dist/installation-service.d.ts +27 -3
  19. package/dist/installation-service.js +72 -3
  20. package/dist/oauth-installation.d.ts +101 -0
  21. package/dist/oauth-installation.js +143 -0
  22. package/dist/oauth-service.d.ts +9 -1
  23. package/dist/oauth-service.js +111 -97
  24. package/dist/oauth-service.test.js +121 -2
  25. package/dist/routes-openapi.d.ts +1 -25
  26. package/dist/routes-viewer-scopes.test.d.ts +1 -0
  27. package/dist/routes-viewer-scopes.test.js +46 -0
  28. package/dist/routes.d.ts +8 -5
  29. package/dist/routes.js +43 -24
  30. package/dist/runtime-contributor.d.ts +13 -1
  31. package/dist/runtime-contributor.js +9 -1
  32. package/dist/runtime-contributor.test.d.ts +1 -0
  33. package/dist/runtime-contributor.test.js +55 -0
  34. package/dist/runtime-port.d.ts +29 -0
  35. package/dist/runtime-port.js +28 -0
  36. package/dist/schema.d.ts +170 -0
  37. package/dist/schema.js +18 -0
  38. package/dist/session-token-service.d.ts +4 -0
  39. package/dist/session-token-service.js +90 -28
  40. package/dist/session-token-service.test.d.ts +1 -0
  41. package/dist/session-token-service.test.js +187 -0
  42. package/dist/session-token.d.ts +13 -2
  43. package/dist/session-token.js +0 -0
  44. package/dist/session-token.test.js +51 -0
  45. package/dist/voyant.js +95 -1
  46. package/migrations/20260718205748_managed_installation_binding.sql +16 -0
  47. package/migrations/meta/_journal.json +8 -1
  48. package/package.json +10 -5
@@ -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,
@@ -55,7 +61,7 @@ export function createAppOAuthService(options) {
55
61
  return { code, state: input.state, redirectUri: input.redirectUri, expiresAt };
56
62
  }
57
63
  async function token(db, input) {
58
- await authenticateClient(db, input.clientId, input.clientSecret);
64
+ await authenticateClient(db, input.clientId, input.clientSecret, options.clientAuthentication === "required");
59
65
  if (input.grantType === "authorization_code")
60
66
  return exchangeCode(db, input);
61
67
  if (input.grantType === "refresh_token")
@@ -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
@@ -82,6 +97,13 @@ export function createAppOAuthService(options) {
82
97
  const scopes = credential.tokenMode === "online"
83
98
  ? (readStoredScopes(credential.encryptedMetadata) ?? [])
84
99
  : await grantedScopes(db, installation.id);
100
+ const appContextConstraint = credential.tokenMode === "online"
101
+ ? readStoredAppContextConstraint(credential.encryptedMetadata)
102
+ : undefined;
103
+ // Every online actor token is minted from an extension session. Missing or
104
+ // malformed context metadata must invalidate it rather than silently widen it.
105
+ if (credential.tokenMode === "online" && !appContextConstraint)
106
+ return null;
85
107
  return {
86
108
  callerType: "app",
87
109
  actor: "staff",
@@ -90,14 +112,23 @@ export function createAppOAuthService(options) {
90
112
  appInstallationId: installation.id,
91
113
  appReleaseId: installation.releaseId,
92
114
  appCredentialGeneration: credential.generation,
115
+ ...(effectiveManagedBinding
116
+ ? {
117
+ appWorkloadEnvironmentId: effectiveManagedBinding.workloadEnvironmentId,
118
+ appContractGeneration: effectiveManagedBinding.contractGeneration,
119
+ }
120
+ : {}),
93
121
  appTokenMode: credential.tokenMode,
94
122
  appViewerId: credential.viewerId ?? undefined,
123
+ ...(appContextConstraint ? { appContextConstraint } : {}),
95
124
  scopes,
96
125
  };
97
126
  }
98
127
  async function revokeInstallationCredentials(db, installationId, actorId) {
99
128
  return db.transaction(async (tx) => {
100
129
  const installation = await requireInstallation(tx, installationId);
130
+ const managedBinding = await resolveManagedBinding(installation.appId, installation.releaseId);
131
+ assertManagedBinding(installation, managedBinding);
101
132
  const generation = installation.credentialGeneration + 1;
102
133
  await tx
103
134
  .update(appInstallations)
@@ -134,13 +165,19 @@ export function createAppOAuthService(options) {
134
165
  if (!verifyPkceS256(input.codeVerifier, code.codeChallenge)) {
135
166
  throw oauthError("invalid_grant", "PKCE verifier does not match the authorization code");
136
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);
137
173
  await tx
138
174
  .update(appOAuthAuthorizationCodes)
139
175
  .set({ consumedAt: now() })
140
176
  .where(eq(appOAuthAuthorizationCodes.id, code.id));
141
177
  const installation = await requireInstallation(tx, code.installationId);
142
178
  assertActiveInstallation(installation);
143
- 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);
144
181
  await audit(tx, installation, code.actorId, "token", "oauth.code.exchanged", {});
145
182
  return tokens;
146
183
  });
@@ -157,8 +194,11 @@ export function createAppOAuthService(options) {
157
194
  throw oauthError("invalid_grant", "Refresh token is invalid");
158
195
  }
159
196
  const installation = await requireInstallation(tx, tokenRow.installationId);
197
+ const managedBinding = await resolveManagedBinding(installation.appId, installation.releaseId);
160
198
  assertTokenClient(installation, input.clientId);
161
199
  assertActiveInstallation(installation);
200
+ assertManagedBinding(tokenRow, managedBinding);
201
+ assertManagedBinding(installation, managedBinding);
162
202
  if (installation.credentialGeneration !== tokenRow.generation) {
163
203
  throw oauthError("invalid_grant", "Refresh token generation was revoked");
164
204
  }
@@ -166,7 +206,7 @@ export function createAppOAuthService(options) {
166
206
  .update(appOAuthRefreshTokens)
167
207
  .set({ status: "inactive", revokedAt: now() })
168
208
  .where(eq(appOAuthRefreshTokens.id, tokenRow.id));
169
- 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, {
170
210
  rotatedFromId: tokenRow.id,
171
211
  });
172
212
  await audit(tx, installation, "app", "token", "oauth.refresh.rotated", {
@@ -177,8 +217,10 @@ export function createAppOAuthService(options) {
177
217
  }
178
218
  async function exchangeActorToken(db, input) {
179
219
  const installation = await requireInstallation(db, input.installationId);
220
+ const managedBinding = await resolveManagedBinding(installation.appId, installation.releaseId);
180
221
  assertTokenClient(installation, input.clientId);
181
222
  assertActiveInstallation(installation);
223
+ assertManagedBinding(installation, managedBinding);
182
224
  const grants = await grantedScopes(db, installation.id);
183
225
  const contextual = input.contextualScopes ?? grants;
184
226
  const scopes = intersectAppTokenScopes(grants, input.viewerScopes, contextual);
@@ -187,11 +229,17 @@ export function createAppOAuthService(options) {
187
229
  await db.insert(appAccessCredentials).values({
188
230
  installationId: installation.id,
189
231
  generation: installation.credentialGeneration,
232
+ workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
233
+ contractGeneration: managedBinding?.contractGeneration,
190
234
  tokenMode: "online",
191
235
  credentialHash: sha256Hex(accessToken),
192
236
  // Online tokens are intentionally narrowed; the resolver must honor the
193
237
  // minted set rather than recomputing from installation grants.
194
- encryptedMetadata: { scopeCount: scopes.length, scopes: [...scopes] },
238
+ encryptedMetadata: {
239
+ scopeCount: scopes.length,
240
+ scopes: [...scopes],
241
+ contextConstraint: input.contextConstraint,
242
+ },
195
243
  status: "active",
196
244
  actorId: input.viewerId,
197
245
  viewerId: input.viewerId,
@@ -202,7 +250,7 @@ export function createAppOAuthService(options) {
202
250
  });
203
251
  return tokenResponse(accessToken, null, expiresAt, scopes, "online");
204
252
  }
205
- async function mintTokens(db, installation, actorId, viewerId, scopes, refreshOptions = {}) {
253
+ async function mintTokens(db, installation, actorId, viewerId, scopes, managedBinding, refreshOptions = {}) {
206
254
  const accessToken = randomToken(APP_ACCESS_TOKEN_PREFIX);
207
255
  const refreshToken = randomToken(APP_REFRESH_TOKEN_PREFIX);
208
256
  const accessExpiresAt = new Date(now().getTime() + OFFLINE_ACCESS_TTL_MS);
@@ -211,6 +259,8 @@ export function createAppOAuthService(options) {
211
259
  await db.insert(appAccessCredentials).values({
212
260
  installationId: installation.id,
213
261
  generation,
262
+ workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
263
+ contractGeneration: managedBinding?.contractGeneration,
214
264
  tokenMode: "offline",
215
265
  credentialHash: sha256Hex(accessToken),
216
266
  encryptedMetadata: { scopeCount: scopes.length },
@@ -223,6 +273,8 @@ export function createAppOAuthService(options) {
223
273
  installationId: installation.id,
224
274
  tokenHash: sha256Hex(refreshToken),
225
275
  generation,
276
+ workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
277
+ contractGeneration: managedBinding?.contractGeneration,
226
278
  rotatedFromId: refreshOptions.rotatedFromId,
227
279
  expiresAt: refreshExpiresAt,
228
280
  });
@@ -231,6 +283,31 @@ export function createAppOAuthService(options) {
231
283
  function isExpired(expiresAt) {
232
284
  return Boolean(expiresAt && expiresAt <= now());
233
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
+ }
234
311
  }
235
312
  function tokenResponse(accessToken, refreshToken, expiresAt, scopes, tokenMode) {
236
313
  return {
@@ -242,14 +319,17 @@ function tokenResponse(accessToken, refreshToken, expiresAt, scopes, tokenMode)
242
319
  ...(refreshToken ? { refreshToken } : {}),
243
320
  };
244
321
  }
245
- async function authenticateClient(db, appId, clientSecret) {
322
+ async function authenticateClient(db, appId, clientSecret, required) {
246
323
  const [secret] = await db
247
324
  .select()
248
325
  .from(appCredentials)
249
326
  .where(and(eq(appCredentials.appId, appId), eq(appCredentials.kind, "client_secret")))
250
327
  .limit(1);
251
- if (!secret)
328
+ if (!secret) {
329
+ if (required)
330
+ throw oauthError("invalid_client", "Client authentication failed", 401);
252
331
  return;
332
+ }
253
333
  if (!clientSecret || !secret.kmsKeyRef.startsWith("sha256:")) {
254
334
  throw oauthError("invalid_client", "Client authentication failed", 401);
255
335
  }
@@ -280,94 +360,6 @@ async function requireExactRedirectUri(db, appId, redirectUri) {
280
360
  if (!row)
281
361
  throw oauthError("invalid_request", "Redirect URI is not registered for this app");
282
362
  }
283
- async function ensureAuthorizedInstallation(db, input) {
284
- return db.transaction(async (tx) => {
285
- const [app] = await tx.select().from(apps).where(eq(apps.id, input.appId)).limit(1);
286
- if (!app)
287
- throw oauthError("invalid_request", "App registration not found", 404);
288
- const [existing] = await tx
289
- .select()
290
- .from(appInstallations)
291
- .where(and(eq(appInstallations.deploymentId, input.deploymentId), eq(appInstallations.appId, input.appId)))
292
- .limit(1);
293
- const installation = existing ??
294
- (await tx
295
- .insert(appInstallations)
296
- .values({
297
- appId: input.appId,
298
- deploymentId: input.deploymentId,
299
- releaseId: input.releaseId,
300
- status: "active",
301
- namespace: app.platformNamespace,
302
- installedBy: input.actorId,
303
- authorizedAt: new Date(),
304
- activatedAt: new Date(),
305
- })
306
- .returning())[0];
307
- if (!installation)
308
- throw oauthError("server_error", "Could not create installation", 500);
309
- for (const scope of input.grantedScopes) {
310
- await tx
311
- .insert(appGrants)
312
- .values({
313
- installationId: installation.id,
314
- scope,
315
- status: "granted",
316
- optional: false,
317
- grantedAt: new Date(),
318
- })
319
- .onConflictDoUpdate({
320
- target: [appGrants.installationId, appGrants.scope],
321
- set: { status: "granted", grantedAt: new Date(), revokedAt: null },
322
- });
323
- }
324
- for (const scope of input.deniedOptionalScopes) {
325
- await tx
326
- .insert(appGrants)
327
- .values({ installationId: installation.id, scope, status: "optional", optional: true })
328
- .onConflictDoUpdate({
329
- target: [appGrants.installationId, appGrants.scope],
330
- set: { status: "optional", optional: true },
331
- });
332
- }
333
- return installation;
334
- });
335
- }
336
- async function requireInstallation(db, installationId) {
337
- const installation = await selectInstallation(db, installationId);
338
- if (!installation)
339
- throw oauthError("invalid_grant", "App installation not found", 404);
340
- return installation;
341
- }
342
- async function selectInstallation(db, installationId) {
343
- const [installation] = await db
344
- .select()
345
- .from(appInstallations)
346
- .where(eq(appInstallations.id, installationId))
347
- .limit(1);
348
- return installation ?? null;
349
- }
350
- async function grantedScopes(db, installationId) {
351
- const rows = await db
352
- .select({ scope: appGrants.scope })
353
- .from(appGrants)
354
- .where(and(eq(appGrants.installationId, installationId), eq(appGrants.status, "granted")))
355
- .orderBy(appGrants.scope);
356
- return rows.map((row) => row.scope);
357
- }
358
- function assertActiveInstallation(installation) {
359
- if (installation.status !== "active") {
360
- throw oauthError("invalid_grant", "App installation is not active");
361
- }
362
- }
363
- function isInstallationUsable(installation, generation) {
364
- return installation?.status === "active" && installation.credentialGeneration === generation;
365
- }
366
- function assertTokenClient(installation, clientId) {
367
- if (installation.appId !== clientId) {
368
- throw oauthError("invalid_grant", "Token belongs to a different app");
369
- }
370
- }
371
363
  export function intersectAppTokenScopes(...sets) {
372
364
  const [first = [], ...rest] = sets;
373
365
  return first.filter((scope) => rest.every((set) => set.includes(scope))).sort();
@@ -382,6 +374,28 @@ export function readStoredScopes(metadata) {
382
374
  return null;
383
375
  return stored.filter((scope) => typeof scope === "string");
384
376
  }
377
+ export function readStoredAppContextConstraint(metadata) {
378
+ const value = metadata.contextConstraint;
379
+ if (!value || typeof value !== "object" || Array.isArray(value))
380
+ return null;
381
+ const record = value;
382
+ const slot = record.slot;
383
+ if (slot !== null && (typeof slot !== "string" || slot.length === 0))
384
+ return null;
385
+ const entity = record.entity;
386
+ if (entity === null)
387
+ return { entity: null, slot };
388
+ if (!entity || typeof entity !== "object" || Array.isArray(entity))
389
+ return null;
390
+ const entityRecord = entity;
391
+ if (typeof entityRecord.type !== "string" ||
392
+ entityRecord.type.length === 0 ||
393
+ typeof entityRecord.id !== "string" ||
394
+ entityRecord.id.length === 0) {
395
+ return null;
396
+ }
397
+ return { entity: { type: entityRecord.type, id: entityRecord.id }, slot };
398
+ }
385
399
  function oauthError(error, description, status = 400) {
386
400
  return new ApiHttpError(description, { status, code: error });
387
401
  }
@@ -1,6 +1,24 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { createAppOAuthService, intersectAppTokenScopes, readStoredScopes, } from "./oauth-service.js";
3
- import { appReleases } from "./schema.js";
2
+ import { createAppOAuthService, intersectAppTokenScopes, readStoredAppContextConstraint, readStoredScopes, } from "./oauth-service.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"]);
@@ -16,6 +34,22 @@ describe("stored online token scopes", () => {
16
34
  expect(readStoredScopes({ scopeCount: 2 })).toBeNull();
17
35
  expect(readStoredScopes({ scopes: "bookings:read" })).toBeNull();
18
36
  });
37
+ it("fails closed unless immutable extension context metadata is complete", () => {
38
+ expect(readStoredAppContextConstraint({
39
+ contextConstraint: {
40
+ entity: { type: "invoice", id: "invoice_1" },
41
+ slot: "invoice.details.after-summary",
42
+ },
43
+ })).toEqual({
44
+ entity: { type: "invoice", id: "invoice_1" },
45
+ slot: "invoice.details.after-summary",
46
+ });
47
+ expect(readStoredAppContextConstraint({})).toBeNull();
48
+ expect(readStoredAppContextConstraint({ contextConstraint: { entity: { type: "invoice" } } })).toBeNull();
49
+ expect(readStoredAppContextConstraint({
50
+ contextConstraint: { entity: { type: "invoice", id: "invoice_1" }, slot: "" },
51
+ })).toBeNull();
52
+ });
19
53
  });
20
54
  describe("authorization release lifecycle", () => {
21
55
  function dbWithRelease(state) {
@@ -58,3 +92,88 @@ describe("authorization release lifecycle", () => {
58
92
  });
59
93
  });
60
94
  });
95
+ describe("managed client authentication", () => {
96
+ it("fails closed when confidential-client provisioning is absent", async () => {
97
+ const db = Object.assign(Object.create(null), {
98
+ select: () => ({
99
+ from: (table) => ({
100
+ where: () => ({ limit: async () => (table === appCredentials ? [] : []) }),
101
+ }),
102
+ }),
103
+ });
104
+ const service = createAppOAuthService({
105
+ accessCatalog: { resources: [], presets: [] },
106
+ deploymentId: "dep_1",
107
+ clientAuthentication: "required",
108
+ });
109
+ await expect(service.token(db, {
110
+ grantType: "refresh_token",
111
+ refreshToken: "app_refresh_fixture",
112
+ clientId: "app_1",
113
+ })).rejects.toMatchObject({ status: 401, code: "invalid_client" });
114
+ });
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
+ });
@@ -91,7 +91,7 @@ export declare const authorizeAppOAuthRoute: {
91
91
  state: z.ZodString;
92
92
  code_challenge: z.ZodString;
93
93
  code_challenge_method: z.ZodLiteral<"S256">;
94
- actor_id: z.ZodString;
94
+ actor_id: z.ZodOptional<z.ZodString>;
95
95
  operator_scopes: z.ZodDefault<z.ZodString>;
96
96
  optional_scopes: z.ZodDefault<z.ZodString>;
97
97
  }, z.core.$strict>;
@@ -147,14 +147,6 @@ export declare const issueAppOAuthTokenRoute: {
147
147
  refresh_token: z.ZodString;
148
148
  client_id: z.ZodString;
149
149
  client_secret: z.ZodOptional<z.ZodString>;
150
- }, z.core.$strip>, z.ZodObject<{
151
- grant_type: z.ZodLiteral<"urn:voyant:params:oauth:grant-type:actor-token-exchange">;
152
- installation_id: z.ZodString;
153
- viewer_id: z.ZodString;
154
- viewer_scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
155
- contextual_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
156
- client_id: z.ZodString;
157
- client_secret: z.ZodOptional<z.ZodString>;
158
150
  }, z.core.$strip>], "grant_type">, z.ZodTransform<{
159
151
  client_secret: string | undefined;
160
152
  grant_type: "authorization_code";
@@ -167,14 +159,6 @@ export declare const issueAppOAuthTokenRoute: {
167
159
  grant_type: "refresh_token";
168
160
  refresh_token: string;
169
161
  client_id: string;
170
- } | {
171
- client_secret: string | undefined;
172
- grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
173
- installation_id: string;
174
- viewer_id: string;
175
- viewer_scopes: string[];
176
- client_id: string;
177
- contextual_scopes?: string[] | undefined;
178
162
  }, {
179
163
  grant_type: "authorization_code";
180
164
  code: string;
@@ -187,14 +171,6 @@ export declare const issueAppOAuthTokenRoute: {
187
171
  refresh_token: string;
188
172
  client_id: string;
189
173
  client_secret?: string | undefined;
190
- } | {
191
- grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
192
- installation_id: string;
193
- viewer_id: string;
194
- viewer_scopes: string[];
195
- client_id: string;
196
- contextual_scopes?: string[] | undefined;
197
- client_secret?: string | undefined;
198
174
  }>>;
199
175
  };
200
176
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveOperatorGrantableRemoteAppScopes, resolveViewerRemoteAppScopes } from "./routes.js";
3
+ const catalog = {
4
+ resources: [
5
+ {
6
+ id: "finance-artifacts",
7
+ unitId: "apps",
8
+ resource: "finance-document-artifacts",
9
+ label: "Artifacts",
10
+ description: "Artifacts",
11
+ wildcard: "explicit-resource",
12
+ remoteSafe: true,
13
+ actions: [
14
+ {
15
+ action: "write",
16
+ label: "Write",
17
+ description: "Write",
18
+ wildcard: "explicit",
19
+ },
20
+ ],
21
+ },
22
+ {
23
+ id: "apps-admin",
24
+ unitId: "apps",
25
+ resource: "apps",
26
+ label: "Apps",
27
+ description: "Apps",
28
+ wildcard: "allow",
29
+ actions: [{ action: "write", label: "Write", description: "Write" }],
30
+ },
31
+ ],
32
+ presets: [],
33
+ };
34
+ describe("viewer scope projection for remote extensions", () => {
35
+ it("projects only host-authorized remote-safe permissions", () => {
36
+ expect(resolveViewerRemoteAppScopes(["finance-document-artifacts:write", "apps:write"], catalog)).toEqual(["finance-document-artifacts:write"]);
37
+ });
38
+ it("does not treat a generic wildcard as an explicit sensitive action", () => {
39
+ expect(resolveViewerRemoteAppScopes(["*"], catalog)).toEqual([]);
40
+ });
41
+ it("derives consent authority from the staff grants and remote-safe catalog", () => {
42
+ expect(resolveOperatorGrantableRemoteAppScopes(["*"], catalog)).toEqual([]);
43
+ expect(resolveOperatorGrantableRemoteAppScopes(["finance-document-artifacts:write", "apps:write"], catalog)).toEqual(["finance-document-artifacts:write"]);
44
+ expect(resolveOperatorGrantableRemoteAppScopes(["apps:write"], catalog)).toEqual([]);
45
+ });
46
+ });
package/dist/routes.d.ts CHANGED
@@ -1,23 +1,23 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { EventBus } from "@voyant-travel/core/events";
3
3
  import type { createCustomFieldsService } from "@voyant-travel/custom-fields";
4
- import type { AccessCatalog } from "@voyant-travel/types/api-keys";
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 = {
9
- Bindings: {
10
- /** Deployment identity; the install lifecycle is scoped to it. */
11
- VOYANT_CLOUD_DEPLOYMENT_ID?: string;
12
- };
13
10
  Variables: {
14
11
  db: PostgresJsDatabase;
12
+ scopes?: string[];
15
13
  };
16
14
  };
17
15
  export interface AppsAdminRouteOptions extends AppsServiceOptions {
18
16
  oauth?: {
19
17
  accessCatalog: AccessCatalog;
20
18
  deploymentId: string;
19
+ managedInstallation?: ManagedAppInstallationAuthority;
20
+ clientAuthentication?: "optional" | "required";
21
21
  };
22
22
  /**
23
23
  * Enables the iframe session-token broker. Requires {@link oauth} for the
@@ -26,6 +26,7 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
26
26
  */
27
27
  sessionToken?: {
28
28
  secret: string;
29
+ managedInstallation?: ManagedAppInstallationAuthority;
29
30
  ttlSeconds?: number;
30
31
  };
31
32
  /**
@@ -39,4 +40,6 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
39
40
  customFields?: CustomFieldsService;
40
41
  }
41
42
  export declare function createAppsAdminRoutes(options?: AppsAdminRouteOptions): OpenAPIHono<Env, {}, "/">;
43
+ export declare function resolveViewerRemoteAppScopes(scopes: readonly string[], catalog: AccessCatalog | undefined): string[];
44
+ export declare function resolveOperatorGrantableRemoteAppScopes(scopes: readonly string[], catalog: AccessCatalog): string[];
42
45
  export {};