@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
package/dist/routes.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import { openApiValidationHook, parseJsonBody, parseQuery, RequestValidationError, requireUserId, } from "@voyant-travel/hono";
3
+ import { hasApiKeyPermission, permissionStringsToPermissions, } from "@voyant-travel/types/api-keys";
3
4
  import { z } from "zod";
5
+ import { grantableRemoteAppScopes } from "./consent.js";
4
6
  import { activateInstallationBodySchema, appCredentialRevocationSchema, appInstallationAuditQuerySchema, appInstallationListQuerySchema, appListQuerySchema, appOAuthAuthorizeQuerySchema, appOAuthTokenSchema, appSessionTokenExchangeSchema, appSessionTokenIssueSchema, appWebhookReplaySchema, createCustomAppRegistrationSchema, installAppSchema, lifecycleActionBodySchema, releaseManifestFetchSchema, releaseManifestUploadSchema, } from "./contracts.js";
5
7
  import { listAppReleases, listInstallationAudit, listInstallationSummaries, loadInstallationDetail, } from "./installation-read-model.js";
6
8
  import { createAppInstallationService } from "./installation-service.js";
@@ -16,16 +18,19 @@ export function createAppsAdminRoutes(options = {}) {
16
18
  const service = createAppsService(options);
17
19
  const installations = createAppInstallationService({
18
20
  deploymentId: options.oauth?.deploymentId ?? options.deploymentId,
21
+ managedInstallation: options.oauth?.managedInstallation,
19
22
  platformApiVersion: options.platformApiVersion,
20
23
  eventBus: options.eventBus,
21
24
  customFields: options.customFields,
22
25
  });
23
26
  const oauth = options.oauth ? createAppOAuthService(options.oauth) : null;
27
+ const oauthAccessCatalog = options.oauth?.accessCatalog;
24
28
  const sessionTokens = oauth && options.oauth && options.sessionToken
25
29
  ? createAppSessionTokenService({
26
30
  secret: options.sessionToken.secret,
27
31
  ttlSeconds: options.sessionToken.ttlSeconds,
28
32
  deploymentId: options.oauth.deploymentId,
33
+ managedInstallation: options.sessionToken.managedInstallation ?? options.oauth.managedInstallation,
29
34
  oauth,
30
35
  })
31
36
  : null;
@@ -42,8 +47,9 @@ export function createAppsAdminRoutes(options = {}) {
42
47
  // so it must never be reachable through read-scoped GET requests. The admin
43
48
  // consent UI submits the approval and performs the redirect itself.
44
49
  routes.openapi(authorizeAppOAuthRoute, async (c) => {
45
- if (!oauth)
50
+ if (!oauth || !oauthAccessCatalog) {
46
51
  return c.json({ error: "App OAuth is not configured" }, 501);
52
+ }
47
53
  const body = await parseJsonBody(c, appOAuthAuthorizeQuerySchema);
48
54
  const result = await oauth.authorize(c.get("db"), {
49
55
  appId: body.client_id,
@@ -52,8 +58,8 @@ export function createAppsAdminRoutes(options = {}) {
52
58
  state: body.state,
53
59
  codeChallenge: body.code_challenge,
54
60
  codeChallengeMethod: body.code_challenge_method,
55
- actorId: body.actor_id,
56
- operatorGrantedScopes: splitScopes(body.operator_scopes),
61
+ actorId: requireUserId(c),
62
+ operatorGrantedScopes: resolveOperatorGrantableRemoteAppScopes(c.get("scopes") ?? [], oauthAccessCatalog),
57
63
  grantedOptionalScopes: splitScopes(body.optional_scopes),
58
64
  });
59
65
  const redirectUrl = new URL(result.redirectUri);
@@ -74,22 +80,12 @@ export function createAppsAdminRoutes(options = {}) {
74
80
  clientId: body.client_id,
75
81
  clientSecret: body.client_secret,
76
82
  })
77
- : body.grant_type === "refresh_token"
78
- ? await oauth.token(c.get("db"), {
79
- grantType: "refresh_token",
80
- refreshToken: body.refresh_token,
81
- clientId: body.client_id,
82
- clientSecret: body.client_secret,
83
- })
84
- : await oauth.token(c.get("db"), {
85
- grantType: "urn:voyant:params:oauth:grant-type:actor-token-exchange",
86
- installationId: body.installation_id,
87
- viewerId: body.viewer_id,
88
- viewerScopes: body.viewer_scopes,
89
- contextualScopes: body.contextual_scopes,
90
- clientId: body.client_id,
91
- clientSecret: body.client_secret,
92
- });
83
+ : await oauth.token(c.get("db"), {
84
+ grantType: "refresh_token",
85
+ refreshToken: body.refresh_token,
86
+ clientId: body.client_id,
87
+ clientSecret: body.client_secret,
88
+ });
93
89
  return c.json(token, 200);
94
90
  });
95
91
  routes.openapi(revokeAppInstallationCredentialsRoute, async (c) => {
@@ -111,6 +107,7 @@ export function createAppsAdminRoutes(options = {}) {
111
107
  const issued = await sessionTokens.issue(c.get("db"), {
112
108
  installationId,
113
109
  viewerId,
110
+ viewerScopes: resolveViewerRemoteAppScopes(c.get("scopes") ?? [], options.oauth?.accessCatalog),
114
111
  entity: body.entity ?? null,
115
112
  slot: body.slot ?? null,
116
113
  });
@@ -133,11 +130,10 @@ export function createAppsAdminRoutes(options = {}) {
133
130
  });
134
131
  routes.openapi(installAppRoute, async (c) => {
135
132
  const body = await parseJsonBody(c, installAppSchema);
136
- // The deployment id is a runtime value (not known at graph-composition
137
- // time), so resolve it per request: explicit body → runtime env →
138
- // construction option. Without this the standard runtime mounts these
139
- // routes with no deployment id and every install 400s (app_deployment_required).
140
- const deploymentId = body.deploymentId ?? c.env?.VOYANT_CLOUD_DEPLOYMENT_ID?.trim() ?? options.deploymentId;
133
+ // A configured managed runtime audience is authoritative so installation
134
+ // identity cannot diverge from later OAuth token audiences. Direct/custom
135
+ // hosts without managed auth may still supply an explicit deployment ID.
136
+ const deploymentId = options.oauth?.deploymentId ?? body.deploymentId ?? options.deploymentId;
141
137
  const result = await installations.install(c.get("db"), {
142
138
  appId: body.appId,
143
139
  releaseId: body.releaseId,
@@ -248,6 +244,29 @@ export function createAppsAdminRoutes(options = {}) {
248
244
  });
249
245
  return routes;
250
246
  }
247
+ export function resolveViewerRemoteAppScopes(scopes, catalog) {
248
+ if (!catalog)
249
+ return [];
250
+ const permissions = permissionStringsToPermissions(scopes);
251
+ return catalog.resources
252
+ .flatMap((resource) => resource.actions
253
+ .filter((action) => (resource.remoteSafe || action.remoteSafe) &&
254
+ hasApiKeyPermission(permissions, resource.resource, action.action, catalog))
255
+ .map((action) => `${resource.resource}:${action.action}`))
256
+ .sort();
257
+ }
258
+ export function resolveOperatorGrantableRemoteAppScopes(scopes, catalog) {
259
+ const grantable = grantableRemoteAppScopes(catalog);
260
+ const permissions = permissionStringsToPermissions(scopes);
261
+ return [...grantable]
262
+ .filter((scope) => {
263
+ const separator = scope.lastIndexOf(":");
264
+ if (separator <= 0)
265
+ return false;
266
+ return hasApiKeyPermission(permissions, scope.slice(0, separator), scope.slice(separator + 1), catalog);
267
+ })
268
+ .sort();
269
+ }
251
270
  function splitScopes(value) {
252
271
  return value
253
272
  .split(/[,\s]+/)
@@ -1 +1,13 @@
1
- export declare function createAppsRuntimePortContribution(): Readonly<Record<string, unknown>>;
1
+ import type { VoyantRuntimeHostPrimitives } from "@voyant-travel/core";
2
+ export interface AppsRuntimeContributorHost {
3
+ primitives: Pick<VoyantRuntimeHostPrimitives, "config">;
4
+ hasRuntimePort?(port: {
5
+ id: string;
6
+ }): boolean;
7
+ }
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
+ */
13
+ export declare function createAppsRuntimePortContribution(host: AppsRuntimeContributorHost): Readonly<Record<string, unknown>>;
@@ -1,3 +1,11 @@
1
- export function createAppsRuntimePortContribution() {
1
+ import { appsManagedAuthRuntimePort } from "./runtime-port.js";
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
+ */
7
+ export function createAppsRuntimePortContribution(host) {
8
+ if (host.hasRuntimePort?.(appsManagedAuthRuntimePort))
9
+ return {};
2
10
  return {};
3
11
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createAppsRuntimePortContribution } from "./runtime-contributor.js";
3
+ import { appsManagedAuthRuntimePort } from "./runtime-port.js";
4
+ function host(values) {
5
+ return {
6
+ hasRuntimePort: () => false,
7
+ primitives: {
8
+ config: { read: (_bindings, key) => values[key] },
9
+ },
10
+ };
11
+ }
12
+ describe("createAppsRuntimePortContribution", () => {
13
+ it("stays off because scalar environment values cannot authorize managed installations", () => {
14
+ expect(createAppsRuntimePortContribution(host({}))).toEqual({});
15
+ expect(createAppsRuntimePortContribution(host({
16
+ VOYANT_APP_RUNTIME_AUDIENCE: "deployment-1",
17
+ VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "s".repeat(32),
18
+ }))).toEqual({});
19
+ });
20
+ it("does not replace an explicitly host-provided managed-auth port", () => {
21
+ const contribution = createAppsRuntimePortContribution({
22
+ ...host({
23
+ VOYANT_APP_RUNTIME_AUDIENCE: "deployment-from-env",
24
+ VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "s".repeat(32),
25
+ }),
26
+ hasRuntimePort: (port) => port.id === appsManagedAuthRuntimePort.id,
27
+ });
28
+ expect(contribution).toEqual({});
29
+ });
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 });
54
+ });
55
+ });
@@ -0,0 +1,29 @@
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
+ }
20
+ export interface AppsManagedAuthRuntime {
21
+ /** Stable audience shared by authorization, installations, and session tokens. */
22
+ runtimeAudience: string;
23
+ /** Provider-neutral managed installation authority supplied by the host. */
24
+ installationAuthority: ManagedAppInstallationAuthority;
25
+ /** Host-owned HMAC material for short-lived extension session tokens. */
26
+ sessionTokenSigningSecret: string;
27
+ sessionTokenTtlSeconds?: number;
28
+ }
29
+ export declare const appsManagedAuthRuntimePort: import("@voyant-travel/core/project").VoyantPort<AppsManagedAuthRuntime>;
@@ -0,0 +1,28 @@
1
+ import { definePort } from "@voyant-travel/core/project";
2
+ export const appsManagedAuthRuntimePort = definePort({
3
+ id: "apps.managed-auth",
4
+ test(runtime) {
5
+ if (!runtime || typeof runtime !== "object") {
6
+ throw new TypeError("apps.managed-auth must be an object.");
7
+ }
8
+ if (!runtime.runtimeAudience?.trim()) {
9
+ throw new TypeError("apps.managed-auth runtimeAudience must be a non-empty string.");
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
+ }
17
+ if (typeof runtime.sessionTokenSigningSecret !== "string" ||
18
+ runtime.sessionTokenSigningSecret.length < 32) {
19
+ throw new TypeError("apps.managed-auth sessionTokenSigningSecret must contain at least 32 characters.");
20
+ }
21
+ if (runtime.sessionTokenTtlSeconds !== undefined &&
22
+ (!Number.isInteger(runtime.sessionTokenTtlSeconds) ||
23
+ runtime.sessionTokenTtlSeconds <= 0 ||
24
+ runtime.sessionTokenTtlSeconds > 300)) {
25
+ throw new TypeError("apps.managed-auth sessionTokenTtlSeconds must be an integer from 1 through 300 when provided.");
26
+ }
27
+ },
28
+ });
package/dist/schema.d.ts CHANGED
@@ -995,6 +995,40 @@ export declare const appInstallations: import("drizzle-orm/pg-core").PgTableWith
995
995
  identity: undefined;
996
996
  generated: undefined;
997
997
  }, {}, {}>;
998
+ workloadEnvironmentId: import("drizzle-orm/pg-core").PgColumn<{
999
+ name: "workload_environment_id";
1000
+ tableName: "app_installations";
1001
+ dataType: "string";
1002
+ columnType: "PgText";
1003
+ data: string;
1004
+ driverParam: string;
1005
+ notNull: false;
1006
+ hasDefault: false;
1007
+ isPrimaryKey: false;
1008
+ isAutoincrement: false;
1009
+ hasRuntimeDefault: false;
1010
+ enumValues: [string, ...string[]];
1011
+ baseColumn: never;
1012
+ identity: undefined;
1013
+ generated: undefined;
1014
+ }, {}, {}>;
1015
+ contractGeneration: import("drizzle-orm/pg-core").PgColumn<{
1016
+ name: "contract_generation";
1017
+ tableName: "app_installations";
1018
+ dataType: "number";
1019
+ columnType: "PgInteger";
1020
+ data: number;
1021
+ driverParam: string | number;
1022
+ notNull: false;
1023
+ hasDefault: false;
1024
+ isPrimaryKey: false;
1025
+ isAutoincrement: false;
1026
+ hasRuntimeDefault: false;
1027
+ enumValues: undefined;
1028
+ baseColumn: never;
1029
+ identity: undefined;
1030
+ generated: undefined;
1031
+ }, {}, {}>;
998
1032
  releaseId: import("drizzle-orm/pg-core").PgColumn<{
999
1033
  name: "release_id";
1000
1034
  tableName: "app_installations";
@@ -1519,6 +1553,40 @@ export declare const appAccessCredentials: import("drizzle-orm/pg-core").PgTable
1519
1553
  identity: undefined;
1520
1554
  generated: undefined;
1521
1555
  }, {}, {}>;
1556
+ workloadEnvironmentId: import("drizzle-orm/pg-core").PgColumn<{
1557
+ name: "workload_environment_id";
1558
+ tableName: "app_access_credentials";
1559
+ dataType: "string";
1560
+ columnType: "PgText";
1561
+ data: string;
1562
+ driverParam: string;
1563
+ notNull: false;
1564
+ hasDefault: false;
1565
+ isPrimaryKey: false;
1566
+ isAutoincrement: false;
1567
+ hasRuntimeDefault: false;
1568
+ enumValues: [string, ...string[]];
1569
+ baseColumn: never;
1570
+ identity: undefined;
1571
+ generated: undefined;
1572
+ }, {}, {}>;
1573
+ contractGeneration: import("drizzle-orm/pg-core").PgColumn<{
1574
+ name: "contract_generation";
1575
+ tableName: "app_access_credentials";
1576
+ dataType: "number";
1577
+ columnType: "PgInteger";
1578
+ data: number;
1579
+ driverParam: string | number;
1580
+ notNull: false;
1581
+ hasDefault: false;
1582
+ isPrimaryKey: false;
1583
+ isAutoincrement: false;
1584
+ hasRuntimeDefault: false;
1585
+ enumValues: undefined;
1586
+ baseColumn: never;
1587
+ identity: undefined;
1588
+ generated: undefined;
1589
+ }, {}, {}>;
1522
1590
  tokenMode: import("drizzle-orm/pg-core").PgColumn<{
1523
1591
  name: "token_mode";
1524
1592
  tableName: "app_access_credentials";
@@ -1766,6 +1834,40 @@ export declare const appOAuthAuthorizationCodes: import("drizzle-orm/pg-core").P
1766
1834
  identity: undefined;
1767
1835
  generated: undefined;
1768
1836
  }, {}, {}>;
1837
+ workloadEnvironmentId: import("drizzle-orm/pg-core").PgColumn<{
1838
+ name: "workload_environment_id";
1839
+ tableName: "app_oauth_authorization_codes";
1840
+ dataType: "string";
1841
+ columnType: "PgText";
1842
+ data: string;
1843
+ driverParam: string;
1844
+ notNull: false;
1845
+ hasDefault: false;
1846
+ isPrimaryKey: false;
1847
+ isAutoincrement: false;
1848
+ hasRuntimeDefault: false;
1849
+ enumValues: [string, ...string[]];
1850
+ baseColumn: never;
1851
+ identity: undefined;
1852
+ generated: undefined;
1853
+ }, {}, {}>;
1854
+ contractGeneration: import("drizzle-orm/pg-core").PgColumn<{
1855
+ name: "contract_generation";
1856
+ tableName: "app_oauth_authorization_codes";
1857
+ dataType: "number";
1858
+ columnType: "PgInteger";
1859
+ data: number;
1860
+ driverParam: string | number;
1861
+ notNull: false;
1862
+ hasDefault: false;
1863
+ isPrimaryKey: false;
1864
+ isAutoincrement: false;
1865
+ hasRuntimeDefault: false;
1866
+ enumValues: undefined;
1867
+ baseColumn: never;
1868
+ identity: undefined;
1869
+ generated: undefined;
1870
+ }, {}, {}>;
1769
1871
  codeHash: import("drizzle-orm/pg-core").PgColumn<{
1770
1872
  name: "code_hash";
1771
1873
  tableName: "app_oauth_authorization_codes";
@@ -2051,6 +2153,40 @@ export declare const appOAuthRefreshTokens: import("drizzle-orm/pg-core").PgTabl
2051
2153
  identity: undefined;
2052
2154
  generated: undefined;
2053
2155
  }, {}, {}>;
2156
+ workloadEnvironmentId: import("drizzle-orm/pg-core").PgColumn<{
2157
+ name: "workload_environment_id";
2158
+ tableName: "app_oauth_refresh_tokens";
2159
+ dataType: "string";
2160
+ columnType: "PgText";
2161
+ data: string;
2162
+ driverParam: string;
2163
+ notNull: false;
2164
+ hasDefault: false;
2165
+ isPrimaryKey: false;
2166
+ isAutoincrement: false;
2167
+ hasRuntimeDefault: false;
2168
+ enumValues: [string, ...string[]];
2169
+ baseColumn: never;
2170
+ identity: undefined;
2171
+ generated: undefined;
2172
+ }, {}, {}>;
2173
+ contractGeneration: import("drizzle-orm/pg-core").PgColumn<{
2174
+ name: "contract_generation";
2175
+ tableName: "app_oauth_refresh_tokens";
2176
+ dataType: "number";
2177
+ columnType: "PgInteger";
2178
+ data: number;
2179
+ driverParam: string | number;
2180
+ notNull: false;
2181
+ hasDefault: false;
2182
+ isPrimaryKey: false;
2183
+ isAutoincrement: false;
2184
+ hasRuntimeDefault: false;
2185
+ enumValues: undefined;
2186
+ baseColumn: never;
2187
+ identity: undefined;
2188
+ generated: undefined;
2189
+ }, {}, {}>;
2054
2190
  status: import("drizzle-orm/pg-core").PgColumn<{
2055
2191
  name: "status";
2056
2192
  tableName: "app_oauth_refresh_tokens";
@@ -3007,6 +3143,40 @@ export declare const appSessionTokens: import("drizzle-orm/pg-core").PgTableWith
3007
3143
  identity: undefined;
3008
3144
  generated: undefined;
3009
3145
  }, {}, {}>;
3146
+ workloadEnvironmentId: import("drizzle-orm/pg-core").PgColumn<{
3147
+ name: "workload_environment_id";
3148
+ tableName: "app_session_tokens";
3149
+ dataType: "string";
3150
+ columnType: "PgText";
3151
+ data: string;
3152
+ driverParam: string;
3153
+ notNull: false;
3154
+ hasDefault: false;
3155
+ isPrimaryKey: false;
3156
+ isAutoincrement: false;
3157
+ hasRuntimeDefault: false;
3158
+ enumValues: [string, ...string[]];
3159
+ baseColumn: never;
3160
+ identity: undefined;
3161
+ generated: undefined;
3162
+ }, {}, {}>;
3163
+ contractGeneration: import("drizzle-orm/pg-core").PgColumn<{
3164
+ name: "contract_generation";
3165
+ tableName: "app_session_tokens";
3166
+ dataType: "number";
3167
+ columnType: "PgInteger";
3168
+ data: number;
3169
+ driverParam: string | number;
3170
+ notNull: false;
3171
+ hasDefault: false;
3172
+ isPrimaryKey: false;
3173
+ isAutoincrement: false;
3174
+ hasRuntimeDefault: false;
3175
+ enumValues: undefined;
3176
+ baseColumn: never;
3177
+ identity: undefined;
3178
+ generated: undefined;
3179
+ }, {}, {}>;
3010
3180
  jti: import("drizzle-orm/pg-core").PgColumn<{
3011
3181
  name: "jti";
3012
3182
  tableName: "app_session_tokens";
package/dist/schema.js CHANGED
@@ -155,6 +155,8 @@ export const appInstallations = pgTable("app_installations", {
155
155
  .notNull()
156
156
  .references(() => apps.id, { onDelete: "cascade" }),
157
157
  deploymentId: text("deployment_id").notNull(),
158
+ workloadEnvironmentId: text("workload_environment_id"),
159
+ contractGeneration: integer("contract_generation"),
158
160
  releaseId: text("release_id")
159
161
  .notNull()
160
162
  .references(() => appReleases.id, { onDelete: "restrict" }),
@@ -182,6 +184,10 @@ export const appInstallations = pgTable("app_installations", {
182
184
  index("idx_app_installations_app").on(table.appId, table.status),
183
185
  index("idx_app_installations_deployment").on(table.deploymentId, table.status),
184
186
  uniqueIndex("uidx_app_installations_deployment_app").on(table.deploymentId, table.appId),
187
+ uniqueIndex("uidx_app_installations_workload_environment_app")
188
+ .on(table.workloadEnvironmentId, table.appId)
189
+ .where(sql `${table.workloadEnvironmentId} IS NOT NULL`),
190
+ check("app_installations_managed_binding_complete", sql `(${table.workloadEnvironmentId} IS NULL AND ${table.contractGeneration} IS NULL) OR (${table.workloadEnvironmentId} IS NOT NULL AND ${table.contractGeneration} > 0)`),
185
191
  // agent-quality: raw-sql reviewed -- owner: apps; identifier is a Drizzle-owned column and the literal prefix is static.
186
192
  check("app_installations_namespace_reserved", sql `${table.namespace} LIKE 'app--%'`),
187
193
  ]);
@@ -206,6 +212,8 @@ export const appAccessCredentials = pgTable("app_access_credentials", {
206
212
  .notNull()
207
213
  .references(() => appInstallations.id, { onDelete: "cascade" }),
208
214
  generation: integer("generation").notNull(),
215
+ workloadEnvironmentId: text("workload_environment_id"),
216
+ contractGeneration: integer("contract_generation"),
209
217
  tokenMode: appAccessTokenModeEnum("token_mode").notNull().default("offline"),
210
218
  credentialHash: text("credential_hash").notNull(),
211
219
  encryptedMetadata: jsonb("encrypted_metadata").$type().notNull(),
@@ -218,6 +226,7 @@ export const appAccessCredentials = pgTable("app_access_credentials", {
218
226
  }, (table) => [
219
227
  index("idx_app_access_credentials_installation").on(table.installationId, table.status),
220
228
  index("idx_app_access_credentials_generation").on(table.installationId, table.tokenMode, table.generation),
229
+ check("app_access_credentials_managed_binding_complete", sql `(${table.workloadEnvironmentId} IS NULL AND ${table.contractGeneration} IS NULL) OR (${table.workloadEnvironmentId} IS NOT NULL AND ${table.contractGeneration} > 0)`),
221
230
  ]);
222
231
  export const appOAuthAuthorizationCodes = pgTable("app_oauth_authorization_codes", {
223
232
  id: typeId("app_oauth_authorization_codes"),
@@ -231,6 +240,8 @@ export const appOAuthAuthorizationCodes = pgTable("app_oauth_authorization_codes
231
240
  .notNull()
232
241
  .references(() => appReleases.id, { onDelete: "cascade" }),
233
242
  deploymentId: text("deployment_id").notNull(),
243
+ workloadEnvironmentId: text("workload_environment_id"),
244
+ contractGeneration: integer("contract_generation"),
234
245
  codeHash: text("code_hash").notNull(),
235
246
  stateHash: text("state_hash").notNull(),
236
247
  redirectUri: text("redirect_uri").notNull(),
@@ -246,6 +257,7 @@ export const appOAuthAuthorizationCodes = pgTable("app_oauth_authorization_codes
246
257
  }, (table) => [
247
258
  uniqueIndex("uidx_app_oauth_codes_hash").on(table.codeHash),
248
259
  index("idx_app_oauth_codes_installation").on(table.installationId, table.expiresAt),
260
+ check("app_oauth_codes_managed_binding_complete", sql `(${table.workloadEnvironmentId} IS NULL AND ${table.contractGeneration} IS NULL) OR (${table.workloadEnvironmentId} IS NOT NULL AND ${table.contractGeneration} > 0)`),
249
261
  ]);
250
262
  export const appOAuthRefreshTokens = pgTable("app_oauth_refresh_tokens", {
251
263
  id: typeId("app_oauth_refresh_tokens"),
@@ -254,6 +266,8 @@ export const appOAuthRefreshTokens = pgTable("app_oauth_refresh_tokens", {
254
266
  .references(() => appInstallations.id, { onDelete: "cascade" }),
255
267
  tokenHash: text("token_hash").notNull(),
256
268
  generation: integer("generation").notNull(),
269
+ workloadEnvironmentId: text("workload_environment_id"),
270
+ contractGeneration: integer("contract_generation"),
257
271
  status: appAccessCredentialStatusEnum("status").notNull().default("active"),
258
272
  rotatedFromId: text("rotated_from_id"),
259
273
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -262,6 +276,7 @@ export const appOAuthRefreshTokens = pgTable("app_oauth_refresh_tokens", {
262
276
  }, (table) => [
263
277
  uniqueIndex("uidx_app_oauth_refresh_tokens_hash").on(table.tokenHash),
264
278
  index("idx_app_oauth_refresh_tokens_installation").on(table.installationId, table.status),
279
+ check("app_oauth_refresh_tokens_managed_binding_complete", sql `(${table.workloadEnvironmentId} IS NULL AND ${table.contractGeneration} IS NULL) OR (${table.workloadEnvironmentId} IS NOT NULL AND ${table.contractGeneration} > 0)`),
265
280
  ]);
266
281
  export const appInstallationSettings = pgTable("app_installation_settings", {
267
282
  id: typeId("app_installation_settings"),
@@ -354,6 +369,8 @@ export const appSessionTokens = pgTable("app_session_tokens", {
354
369
  .references(() => appInstallations.id, { onDelete: "cascade" }),
355
370
  appId: text("app_id").notNull(),
356
371
  deploymentId: text("deployment_id").notNull(),
372
+ workloadEnvironmentId: text("workload_environment_id"),
373
+ contractGeneration: integer("contract_generation"),
357
374
  jti: text("jti").notNull(),
358
375
  viewerId: text("viewer_id").notNull(),
359
376
  entityType: text("entity_type"),
@@ -366,4 +383,5 @@ export const appSessionTokens = pgTable("app_session_tokens", {
366
383
  }, (table) => [
367
384
  uniqueIndex("uidx_app_session_tokens_jti").on(table.jti),
368
385
  index("idx_app_session_tokens_installation").on(table.installationId, table.expiresAt),
386
+ check("app_session_tokens_managed_binding_complete", sql `(${table.workloadEnvironmentId} IS NULL AND ${table.contractGeneration} IS NULL) OR (${table.workloadEnvironmentId} IS NOT NULL AND ${table.contractGeneration} > 0)`),
369
387
  ]);
@@ -1,5 +1,6 @@
1
1
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
2
  import type { createAppOAuthService } from "./oauth-service.js";
3
+ import type { ManagedAppInstallationAuthority } from "./runtime-port.js";
3
4
  import { type AppSessionTokenEntity } from "./session-token.js";
4
5
  type AppOAuthService = ReturnType<typeof createAppOAuthService>;
5
6
  export interface AppSessionTokenServiceOptions {
@@ -7,6 +8,7 @@ export interface AppSessionTokenServiceOptions {
7
8
  secret: string;
8
9
  /** The deployment audience the tokens are bound to. */
9
10
  deploymentId: string;
11
+ managedInstallation?: ManagedAppInstallationAuthority;
10
12
  /** OAuth service supplying the online actor-token-exchange primitive. */
11
13
  oauth: AppOAuthService;
12
14
  ttlSeconds?: number;
@@ -15,6 +17,8 @@ export interface AppSessionTokenServiceOptions {
15
17
  export interface IssueAppSessionTokenInput {
16
18
  installationId: string;
17
19
  viewerId: string;
20
+ /** Current host-authenticated viewer permissions. */
21
+ viewerScopes?: readonly string[];
18
22
  entity?: AppSessionTokenEntity | null;
19
23
  slot?: string | null;
20
24
  }