@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/access-boundary.d.ts +2 -0
- package/dist/api-runtime.js +2 -0
- package/dist/api-runtime.test.js +4 -0
- package/dist/app-api-service.d.ts +2 -0
- package/dist/compiler.test.js +7 -0
- package/dist/contracts.d.ts +1 -1
- package/dist/contracts.js +1 -1
- package/dist/installation-service.d.ts +27 -3
- package/dist/installation-service.js +72 -3
- package/dist/oauth-installation.d.ts +101 -0
- package/dist/oauth-installation.js +143 -0
- package/dist/oauth-service.d.ts +3 -0
- package/dist/oauth-service.js +70 -93
- package/dist/oauth-service.test.js +83 -1
- package/dist/routes.d.ts +3 -0
- package/dist/routes.js +2 -0
- package/dist/runtime-contributor.d.ts +5 -1
- package/dist/runtime-contributor.js +6 -21
- package/dist/runtime-contributor.test.js +28 -25
- package/dist/runtime-port.d.ts +21 -0
- package/dist/runtime-port.js +6 -0
- package/dist/schema.d.ts +170 -0
- package/dist/schema.js +18 -0
- package/dist/session-token-service.d.ts +2 -0
- package/dist/session-token-service.js +48 -3
- package/dist/session-token-service.test.js +31 -1
- package/dist/session-token.d.ts +8 -1
- package/dist/session-token.js +0 -0
- package/dist/session-token.test.js +46 -0
- package/migrations/20260718205748_managed_installation_binding.sql +16 -0
- package/migrations/meta/_journal.json +8 -1
- package/package.json +6 -6
|
@@ -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;
|
package/dist/api-runtime.js
CHANGED
|
@@ -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 }),
|
package/dist/api-runtime.test.js
CHANGED
|
@@ -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;
|
package/dist/compiler.test.js
CHANGED
|
@@ -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({
|
package/dist/contracts.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
+
}
|
package/dist/oauth-service.d.ts
CHANGED
|
@@ -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;
|