@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.
@@ -7,6 +7,8 @@ export declare function assertActiveAppInstallationAccess(db: PostgresJsDatabase
7
7
  id: string;
8
8
  appId: string;
9
9
  deploymentId: string;
10
+ workloadEnvironmentId: string | null;
11
+ contractGeneration: number | null;
10
12
  releaseId: string;
11
13
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
12
14
  namespace: string;
@@ -20,6 +20,7 @@ export const createAppsApiModule = defineGraphRuntimeFactory(async ({ getPort, g
20
20
  ? {
21
21
  accessCatalog: graph.accessCatalog,
22
22
  deploymentId: managedAuth.runtimeAudience,
23
+ managedInstallation: managedAuth.installationAuthority,
23
24
  clientAuthentication: "required",
24
25
  }
25
26
  : undefined;
@@ -33,6 +34,7 @@ export const createAppsApiModule = defineGraphRuntimeFactory(async ({ getPort, g
33
34
  ? {
34
35
  sessionToken: {
35
36
  secret: managedAuth.sessionTokenSigningSecret,
37
+ managedInstallation: managedAuth.installationAuthority,
36
38
  ...(managedAuth.sessionTokenTtlSeconds === undefined
37
39
  ? {}
38
40
  : { ttlSeconds: managedAuth.sessionTokenTtlSeconds }),
@@ -36,6 +36,10 @@ describe("createAppsApiModule", () => {
36
36
  it("composes OAuth, session exchange, and token resolution from the host port", async () => {
37
37
  const module = await createAppsApiModule(context({
38
38
  runtimeAudience: "deployment-1",
39
+ installationAuthority: {
40
+ workloadEnvironmentId: "workload-environment-1",
41
+ resolveInstallationContract: async () => ({ contractGeneration: 1 }),
42
+ },
39
43
  sessionTokenSigningSecret: "s".repeat(32),
40
44
  sessionTokenTtlSeconds: 180,
41
45
  }));
@@ -1259,6 +1259,8 @@ export declare function createAppApiService(options?: AppApiServiceOptions): {
1259
1259
  id: string;
1260
1260
  appId: string;
1261
1261
  deploymentId: string;
1262
+ workloadEnvironmentId: string | null;
1263
+ contractGeneration: number | null;
1262
1264
  releaseId: string;
1263
1265
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
1264
1266
  namespace: string;
@@ -3,6 +3,13 @@ import { compileAppManifest } from "./compiler.js";
3
3
  import { appManifestSchema } from "./contracts.js";
4
4
  import { validManifest } from "./test-fixtures.js";
5
5
  describe("app manifest compiler", () => {
6
+ it("accepts truthful disclosure of publisher-custodied encrypted secrets", () => {
7
+ const parsed = appManifestSchema.parse({
8
+ ...validManifest,
9
+ data: { ...validManifest.data, storesSecrets: true },
10
+ });
11
+ expect(parsed.data.storesSecrets).toBe(true);
12
+ });
6
13
  it("accepts a closed v1 manifest and produces a stable digest", () => {
7
14
  const first = compileAppManifest(validManifest);
8
15
  const second = compileAppManifest({
@@ -142,7 +142,7 @@ export declare const appManifestSchema: z.ZodPipe<z.ZodRecord<z.ZodString, z.Zod
142
142
  financial: "financial";
143
143
  }>>;
144
144
  retention: z.ZodString;
145
- storesSecrets: z.ZodDefault<z.ZodLiteral<false>>;
145
+ storesSecrets: z.ZodDefault<z.ZodBoolean>;
146
146
  }, z.core.$strict>;
147
147
  }, z.core.$strict>>;
148
148
  export declare const createCustomAppRegistrationSchema: z.ZodObject<{
package/dist/contracts.js CHANGED
@@ -161,7 +161,7 @@ export const appManifestSchema = manifestDisallowedKeySchema.pipe(z
161
161
  .object({
162
162
  classifications: z.array(dataClassificationSchema).min(1),
163
163
  retention: z.string().trim().min(1).max(280),
164
- storesSecrets: z.literal(false).default(false),
164
+ storesSecrets: z.boolean().default(false),
165
165
  })
166
166
  .strict(),
167
167
  })
@@ -1,6 +1,7 @@
1
1
  import type { EventBus } from "@voyant-travel/core/events";
2
2
  import type { createCustomFieldsService } from "@voyant-travel/custom-fields";
3
3
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
4
+ import type { ManagedAppInstallationAuthority } from "./runtime-port.js";
4
5
  import { type AppInstallation } from "./schema.js";
5
6
  export type AppInstallationStatus = AppInstallation["status"];
6
7
  export type AppInstallationUpdatePolicy = AppInstallation["updatePolicy"];
@@ -15,6 +16,7 @@ export interface AppInstallationServiceOptions {
15
16
  customFields?: CustomFieldsService;
16
17
  platformApiVersion?: string;
17
18
  deploymentId?: string;
19
+ managedInstallation?: ManagedAppInstallationAuthority;
18
20
  }
19
21
  export interface InstallAppInput {
20
22
  appId: string;
@@ -52,9 +54,13 @@ export declare function createAppInstallationService(options?: AppInstallationSe
52
54
  install: (db: PostgresJsDatabase, input: InstallAppInput) => Promise<{
53
55
  installation: {
54
56
  id: string;
57
+ createdAt: Date;
58
+ updatedAt: Date;
55
59
  appId: string;
56
- deploymentId: string;
57
60
  releaseId: string;
61
+ deploymentId: string;
62
+ workloadEnvironmentId: string | null;
63
+ contractGeneration: number | null;
58
64
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
59
65
  namespace: string;
60
66
  installedBy: string;
@@ -71,8 +77,6 @@ export declare function createAppInstallationService(options?: AppInstallationSe
71
77
  revokedAt: Date | null;
72
78
  uninstalledAt: Date | null;
73
79
  purgedAt: Date | null;
74
- createdAt: Date;
75
- updatedAt: Date;
76
80
  };
77
81
  outcome: "unchanged";
78
82
  } | {
@@ -83,6 +87,8 @@ export declare function createAppInstallationService(options?: AppInstallationSe
83
87
  appId: string;
84
88
  releaseId: string;
85
89
  deploymentId: string;
90
+ workloadEnvironmentId: string | null;
91
+ contractGeneration: number | null;
86
92
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
87
93
  namespace: string;
88
94
  installedBy: string;
@@ -107,6 +113,8 @@ export declare function createAppInstallationService(options?: AppInstallationSe
107
113
  id: string;
108
114
  appId: string;
109
115
  deploymentId: string;
116
+ workloadEnvironmentId: string | null;
117
+ contractGeneration: number | null;
110
118
  releaseId: string;
111
119
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
112
120
  namespace: string;
@@ -134,6 +142,8 @@ export declare function createAppInstallationService(options?: AppInstallationSe
134
142
  id: string;
135
143
  appId: string;
136
144
  deploymentId: string;
145
+ workloadEnvironmentId: string | null;
146
+ contractGeneration: number | null;
137
147
  releaseId: string;
138
148
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
139
149
  namespace: string;
@@ -162,6 +172,8 @@ export declare function createAppInstallationService(options?: AppInstallationSe
162
172
  id: string;
163
173
  appId: string;
164
174
  deploymentId: string;
175
+ workloadEnvironmentId: string | null;
176
+ contractGeneration: number | null;
165
177
  releaseId: string;
166
178
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
167
179
  namespace: string;
@@ -188,6 +200,8 @@ export declare function createAppInstallationService(options?: AppInstallationSe
188
200
  id: string;
189
201
  appId: string;
190
202
  deploymentId: string;
203
+ workloadEnvironmentId: string | null;
204
+ contractGeneration: number | null;
191
205
  releaseId: string;
192
206
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
193
207
  namespace: string;
@@ -215,6 +229,8 @@ export declare function createAppInstallationService(options?: AppInstallationSe
215
229
  id: string;
216
230
  appId: string;
217
231
  deploymentId: string;
232
+ workloadEnvironmentId: string | null;
233
+ contractGeneration: number | null;
218
234
  releaseId: string;
219
235
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
220
236
  namespace: string;
@@ -241,6 +257,8 @@ export declare function createAppInstallationService(options?: AppInstallationSe
241
257
  id: string;
242
258
  appId: string;
243
259
  deploymentId: string;
260
+ workloadEnvironmentId: string | null;
261
+ contractGeneration: number | null;
244
262
  releaseId: string;
245
263
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
246
264
  namespace: string;
@@ -268,6 +286,8 @@ export declare function createAppInstallationService(options?: AppInstallationSe
268
286
  id: string;
269
287
  appId: string;
270
288
  deploymentId: string;
289
+ workloadEnvironmentId: string | null;
290
+ contractGeneration: number | null;
271
291
  releaseId: string;
272
292
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
273
293
  namespace: string;
@@ -294,6 +314,8 @@ export declare function createAppInstallationService(options?: AppInstallationSe
294
314
  id: string;
295
315
  appId: string;
296
316
  deploymentId: string;
317
+ workloadEnvironmentId: string | null;
318
+ contractGeneration: number | null;
297
319
  releaseId: string;
298
320
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
299
321
  namespace: string;
@@ -321,6 +343,8 @@ export declare function createAppInstallationService(options?: AppInstallationSe
321
343
  id: string;
322
344
  appId: string;
323
345
  deploymentId: string;
346
+ workloadEnvironmentId: string | null;
347
+ contractGeneration: number | null;
324
348
  releaseId: string;
325
349
  status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
326
350
  namespace: string;
@@ -23,10 +23,14 @@ export function createAppInstallationService(options = {}) {
23
23
  const resolvedDeploymentId = deploymentId(input);
24
24
  return db.transaction(async (tx) => {
25
25
  const release = await requireReleaseForApp(tx, input.appId, input.releaseId);
26
+ const managedBinding = await resolveManagedBinding(options.managedInstallation, input.appId, input.releaseId);
26
27
  assertReleaseAvailable(release);
27
28
  assertApiCompatible(release, options.platformApiVersion);
28
29
  const app = await requireApp(tx, input.appId);
29
- const existing = await selectInstallationByDeploymentApp(tx, resolvedDeploymentId, input.appId, true);
30
+ const selected = await selectInstallationForApp(tx, resolvedDeploymentId, input.appId, managedBinding, true);
31
+ const existing = selected
32
+ ? await reconcileManagedInstallationBinding(tx, selected, resolvedDeploymentId, managedBinding)
33
+ : null;
30
34
  if (existing && !canInstallOver(existing.status)) {
31
35
  if (existing.releaseId !== input.releaseId) {
32
36
  throw invalidTransition(existing.status, "install_different_release");
@@ -36,7 +40,7 @@ export function createAppInstallationService(options = {}) {
36
40
  }
37
41
  const installation = existing
38
42
  ? await reactivateInstallation(tx, existing, release, input)
39
- : await createInstallation(tx, app, release, resolvedDeploymentId, input);
43
+ : await createInstallation(tx, app, release, resolvedDeploymentId, input, managedBinding);
40
44
  await reconcileRelease(tx, installation, release, input.actorId, "install", options);
41
45
  await reconcileGrants(tx, installation, release, input.actorId, input.grantedOptionalScopes);
42
46
  if (input.credential) {
@@ -192,13 +196,15 @@ export function createAppInstallationService(options = {}) {
192
196
  });
193
197
  }
194
198
  }
195
- async function createInstallation(db, app, release, deploymentId, input) {
199
+ async function createInstallation(db, app, release, deploymentId, input, managedBinding) {
196
200
  const now = new Date();
197
201
  const [row] = await db
198
202
  .insert(appInstallations)
199
203
  .values({
200
204
  appId: app.id,
201
205
  deploymentId,
206
+ workloadEnvironmentId: managedBinding?.workloadEnvironmentId,
207
+ contractGeneration: managedBinding?.contractGeneration,
202
208
  releaseId: release.id,
203
209
  status: "active",
204
210
  namespace: app.platformNamespace,
@@ -272,6 +278,69 @@ async function selectInstallationByDeploymentApp(db, deploymentId, appId, lock)
272
278
  const [row] = lock ? await query.for("update").limit(1) : await query.limit(1);
273
279
  return row ?? null;
274
280
  }
281
+ async function selectInstallationForApp(db, deploymentId, appId, managedBinding, lock) {
282
+ if (!managedBinding) {
283
+ return selectInstallationByDeploymentApp(db, deploymentId, appId, lock);
284
+ }
285
+ const query = db
286
+ .select()
287
+ .from(appInstallations)
288
+ .where(and(eq(appInstallations.workloadEnvironmentId, managedBinding.workloadEnvironmentId), eq(appInstallations.appId, appId)));
289
+ const [row] = lock ? await query.for("update").limit(1) : await query.limit(1);
290
+ return row ?? null;
291
+ }
292
+ async function reconcileManagedInstallationBinding(db, installation, deploymentId, managedBinding) {
293
+ if (!managedBinding)
294
+ return installation;
295
+ if (installation.workloadEnvironmentId !== managedBinding.workloadEnvironmentId ||
296
+ installation.contractGeneration === null) {
297
+ throw new ApiHttpError("Managed app installation binding does not match this runtime", {
298
+ status: 409,
299
+ code: "app_managed_installation_binding_mismatch",
300
+ });
301
+ }
302
+ if (installation.contractGeneration > managedBinding.contractGeneration) {
303
+ throw new ApiHttpError("Managed app contract generation is stale", {
304
+ status: 409,
305
+ code: "app_managed_contract_generation_stale",
306
+ });
307
+ }
308
+ if (installation.contractGeneration === managedBinding.contractGeneration &&
309
+ installation.deploymentId === deploymentId) {
310
+ return installation;
311
+ }
312
+ const generationAdvanced = installation.contractGeneration < managedBinding.contractGeneration;
313
+ const [updated] = await db
314
+ .update(appInstallations)
315
+ .set({
316
+ deploymentId,
317
+ contractGeneration: managedBinding.contractGeneration,
318
+ credentialGeneration: generationAdvanced
319
+ ? sql `${appInstallations.credentialGeneration} + 1`
320
+ : installation.credentialGeneration,
321
+ updatedAt: new Date(),
322
+ })
323
+ .where(eq(appInstallations.id, installation.id))
324
+ .returning();
325
+ return updated ?? installation;
326
+ }
327
+ async function resolveManagedBinding(authority, appId, releaseId) {
328
+ if (!authority)
329
+ return undefined;
330
+ const contract = await authority.resolveInstallationContract({ appId, releaseId });
331
+ if (!contract ||
332
+ !Number.isSafeInteger(contract.contractGeneration) ||
333
+ contract.contractGeneration <= 0) {
334
+ throw new ApiHttpError("Managed app installation contract is unavailable", {
335
+ status: 409,
336
+ code: "app_managed_installation_contract_unavailable",
337
+ });
338
+ }
339
+ return {
340
+ workloadEnvironmentId: authority.workloadEnvironmentId,
341
+ contractGeneration: contract.contractGeneration,
342
+ };
343
+ }
275
344
  function assertReleaseAvailable(release) {
276
345
  if (release.state !== "available") {
277
346
  throw new ApiHttpError("App release is not available", {
@@ -0,0 +1,101 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ import type { ManagedAppInstallationAuthority, ManagedAppInstallationBinding } from "./runtime-port.js";
3
+ import { type AppInstallation } from "./schema.js";
4
+ export declare function ensureAuthorizedInstallation(db: PostgresJsDatabase, input: {
5
+ appId: string;
6
+ releaseId: string;
7
+ deploymentId: string;
8
+ actorId: string;
9
+ grantedScopes: readonly string[];
10
+ deniedOptionalScopes: readonly string[];
11
+ managedBinding?: ManagedAppInstallationBinding;
12
+ }): Promise<{
13
+ id: string;
14
+ createdAt: Date;
15
+ updatedAt: Date;
16
+ appId: string;
17
+ releaseId: string;
18
+ deploymentId: string;
19
+ workloadEnvironmentId: string | null;
20
+ contractGeneration: number | null;
21
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
22
+ namespace: string;
23
+ installedBy: string;
24
+ credentialGeneration: number;
25
+ updatePolicy: "manual" | "compatible" | "patch" | "pinned";
26
+ lastCompatibleReleaseCheckAt: Date | null;
27
+ pendingReleaseId: string | null;
28
+ pendingReason: string | null;
29
+ installedAt: Date;
30
+ authorizedAt: Date | null;
31
+ activatedAt: Date | null;
32
+ pausedAt: Date | null;
33
+ degradedAt: Date | null;
34
+ revokedAt: Date | null;
35
+ uninstalledAt: Date | null;
36
+ purgedAt: Date | null;
37
+ }>;
38
+ export declare function requireInstallation(db: PostgresJsDatabase, installationId: string): Promise<{
39
+ id: string;
40
+ appId: string;
41
+ deploymentId: string;
42
+ workloadEnvironmentId: string | null;
43
+ contractGeneration: number | null;
44
+ releaseId: string;
45
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
46
+ namespace: string;
47
+ installedBy: string;
48
+ credentialGeneration: number;
49
+ updatePolicy: "manual" | "compatible" | "patch" | "pinned";
50
+ lastCompatibleReleaseCheckAt: Date | null;
51
+ pendingReleaseId: string | null;
52
+ pendingReason: string | null;
53
+ installedAt: Date;
54
+ authorizedAt: Date | null;
55
+ activatedAt: Date | null;
56
+ pausedAt: Date | null;
57
+ degradedAt: Date | null;
58
+ revokedAt: Date | null;
59
+ uninstalledAt: Date | null;
60
+ purgedAt: Date | null;
61
+ createdAt: Date;
62
+ updatedAt: Date;
63
+ }>;
64
+ export declare function selectInstallation(db: PostgresJsDatabase, installationId: string): Promise<{
65
+ id: string;
66
+ appId: string;
67
+ deploymentId: string;
68
+ workloadEnvironmentId: string | null;
69
+ contractGeneration: number | null;
70
+ releaseId: string;
71
+ status: "active" | "pending" | "authorizing" | "paused" | "degraded" | "revoked" | "uninstalled";
72
+ namespace: string;
73
+ installedBy: string;
74
+ credentialGeneration: number;
75
+ updatePolicy: "manual" | "compatible" | "patch" | "pinned";
76
+ lastCompatibleReleaseCheckAt: Date | null;
77
+ pendingReleaseId: string | null;
78
+ pendingReason: string | null;
79
+ installedAt: Date;
80
+ authorizedAt: Date | null;
81
+ activatedAt: Date | null;
82
+ pausedAt: Date | null;
83
+ degradedAt: Date | null;
84
+ revokedAt: Date | null;
85
+ uninstalledAt: Date | null;
86
+ purgedAt: Date | null;
87
+ createdAt: Date;
88
+ updatedAt: Date;
89
+ } | null>;
90
+ export declare function grantedScopes(db: PostgresJsDatabase, installationId: string): Promise<string[]>;
91
+ export declare function assertActiveInstallation(installation: AppInstallation): void;
92
+ export declare function isInstallationUsable(installation: AppInstallation | null, generation: number): boolean;
93
+ export declare function assertTokenClient(installation: AppInstallation, clientId: string): void;
94
+ type PersistedManagedBinding = {
95
+ workloadEnvironmentId: string | null;
96
+ contractGeneration: number | null;
97
+ };
98
+ export declare function assertManagedInstallationAuthority(authority: ManagedAppInstallationAuthority | undefined): void;
99
+ export declare function managedBindingMatches(persisted: PersistedManagedBinding, expected: ManagedAppInstallationBinding | undefined): boolean;
100
+ export declare function assertManagedBinding(persisted: PersistedManagedBinding, expected: ManagedAppInstallationBinding | undefined): void;
101
+ export {};
@@ -0,0 +1,143 @@
1
+ import { ApiHttpError } from "@voyant-travel/hono";
2
+ import { and, eq, sql } from "drizzle-orm";
3
+ import { appGrants, appInstallations, apps } from "./schema.js";
4
+ export async function ensureAuthorizedInstallation(db, input) {
5
+ return db.transaction(async (tx) => {
6
+ const [app] = await tx.select().from(apps).where(eq(apps.id, input.appId)).limit(1);
7
+ if (!app)
8
+ throw oauthError("invalid_request", "App registration not found", 404);
9
+ const [existing] = await tx
10
+ .select()
11
+ .from(appInstallations)
12
+ .where(input.managedBinding
13
+ ? and(eq(appInstallations.workloadEnvironmentId, input.managedBinding.workloadEnvironmentId), eq(appInstallations.appId, input.appId))
14
+ : and(eq(appInstallations.deploymentId, input.deploymentId), eq(appInstallations.appId, input.appId)))
15
+ .limit(1);
16
+ let installation = existing ??
17
+ (await tx
18
+ .insert(appInstallations)
19
+ .values({
20
+ appId: input.appId,
21
+ deploymentId: input.deploymentId,
22
+ workloadEnvironmentId: input.managedBinding?.workloadEnvironmentId,
23
+ contractGeneration: input.managedBinding?.contractGeneration,
24
+ releaseId: input.releaseId,
25
+ status: "active",
26
+ namespace: app.platformNamespace,
27
+ installedBy: input.actorId,
28
+ authorizedAt: new Date(),
29
+ activatedAt: new Date(),
30
+ })
31
+ .returning())[0];
32
+ if (!installation)
33
+ throw oauthError("server_error", "Could not create installation", 500);
34
+ if (existing && input.managedBinding) {
35
+ if (existing.contractGeneration === null) {
36
+ throw oauthError("invalid_grant", "Managed installation is missing its workload-environment binding");
37
+ }
38
+ if (existing.contractGeneration > input.managedBinding.contractGeneration) {
39
+ throw oauthError("invalid_grant", "Managed app contract generation is stale");
40
+ }
41
+ if (existing.contractGeneration < input.managedBinding.contractGeneration ||
42
+ existing.deploymentId !== input.deploymentId) {
43
+ const generationAdvanced = existing.contractGeneration < input.managedBinding.contractGeneration;
44
+ const [updated] = await tx
45
+ .update(appInstallations)
46
+ .set({
47
+ deploymentId: input.deploymentId,
48
+ contractGeneration: input.managedBinding.contractGeneration,
49
+ // agent-quality: raw-sql reviewed -- owner: apps; atomically invalidates the prior credential generation.
50
+ credentialGeneration: generationAdvanced
51
+ ? sql `${appInstallations.credentialGeneration} + 1`
52
+ : existing.credentialGeneration,
53
+ updatedAt: new Date(),
54
+ })
55
+ .where(eq(appInstallations.id, existing.id))
56
+ .returning();
57
+ installation = updated ?? existing;
58
+ }
59
+ }
60
+ for (const scope of input.grantedScopes) {
61
+ await tx
62
+ .insert(appGrants)
63
+ .values({
64
+ installationId: installation.id,
65
+ scope,
66
+ status: "granted",
67
+ optional: false,
68
+ grantedAt: new Date(),
69
+ })
70
+ .onConflictDoUpdate({
71
+ target: [appGrants.installationId, appGrants.scope],
72
+ set: { status: "granted", grantedAt: new Date(), revokedAt: null },
73
+ });
74
+ }
75
+ for (const scope of input.deniedOptionalScopes) {
76
+ await tx
77
+ .insert(appGrants)
78
+ .values({ installationId: installation.id, scope, status: "optional", optional: true })
79
+ .onConflictDoUpdate({
80
+ target: [appGrants.installationId, appGrants.scope],
81
+ set: { status: "optional", optional: true },
82
+ });
83
+ }
84
+ return installation;
85
+ });
86
+ }
87
+ export async function requireInstallation(db, installationId) {
88
+ const installation = await selectInstallation(db, installationId);
89
+ if (!installation)
90
+ throw oauthError("invalid_grant", "App installation not found", 404);
91
+ return installation;
92
+ }
93
+ export async function selectInstallation(db, installationId) {
94
+ const [installation] = await db
95
+ .select()
96
+ .from(appInstallations)
97
+ .where(eq(appInstallations.id, installationId))
98
+ .limit(1);
99
+ return installation ?? null;
100
+ }
101
+ export async function grantedScopes(db, installationId) {
102
+ const rows = await db
103
+ .select({ scope: appGrants.scope })
104
+ .from(appGrants)
105
+ .where(and(eq(appGrants.installationId, installationId), eq(appGrants.status, "granted")))
106
+ .orderBy(appGrants.scope);
107
+ return rows.map((row) => row.scope);
108
+ }
109
+ export function assertActiveInstallation(installation) {
110
+ if (installation.status !== "active") {
111
+ throw oauthError("invalid_grant", "App installation is not active");
112
+ }
113
+ }
114
+ export function isInstallationUsable(installation, generation) {
115
+ return installation?.status === "active" && installation.credentialGeneration === generation;
116
+ }
117
+ export function assertTokenClient(installation, clientId) {
118
+ if (installation.appId !== clientId) {
119
+ throw oauthError("invalid_grant", "Token belongs to a different app");
120
+ }
121
+ }
122
+ export function assertManagedInstallationAuthority(authority) {
123
+ if (!authority)
124
+ return;
125
+ if (!authority.workloadEnvironmentId.trim() ||
126
+ typeof authority.resolveInstallationContract !== "function") {
127
+ throw new TypeError("Managed app installation authority is invalid");
128
+ }
129
+ }
130
+ export function managedBindingMatches(persisted, expected) {
131
+ if (!expected)
132
+ return true;
133
+ return (persisted.workloadEnvironmentId === expected.workloadEnvironmentId &&
134
+ persisted.contractGeneration === expected.contractGeneration);
135
+ }
136
+ export function assertManagedBinding(persisted, expected) {
137
+ if (!managedBindingMatches(persisted, expected)) {
138
+ throw oauthError("invalid_grant", "App credential belongs to a different workload environment or contract generation");
139
+ }
140
+ }
141
+ function oauthError(error, description, status = 400) {
142
+ return new ApiHttpError(description, { status, code: error });
143
+ }
@@ -1,9 +1,12 @@
1
1
  import type { VoyantAppContextConstraint, VoyantAuthContext } from "@voyant-travel/core";
2
2
  import type { AccessCatalog } from "@voyant-travel/types/api-keys";
3
3
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
4
+ import type { ManagedAppInstallationAuthority } from "./runtime-port.js";
4
5
  export interface AppOAuthServiceOptions {
5
6
  accessCatalog: AccessCatalog;
6
7
  deploymentId: string;
8
+ /** Stable host identity required only for managed workload environments. */
9
+ managedInstallation?: ManagedAppInstallationAuthority;
7
10
  /** Managed confidential runtimes fail closed unless a client secret is registered. */
8
11
  clientAuthentication?: "optional" | "required";
9
12
  now?: () => Date;