@voyant-travel/apps 0.6.3 → 0.8.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 (40) hide show
  1. package/dist/api-runtime.d.ts +23 -11
  2. package/dist/api-runtime.js +44 -2
  3. package/dist/api-runtime.test.d.ts +1 -0
  4. package/dist/api-runtime.test.js +54 -0
  5. package/dist/app-api-contracts.d.ts +84 -7
  6. package/dist/app-api-contracts.js +130 -9
  7. package/dist/app-api-finance-routes.test.d.ts +1 -0
  8. package/dist/app-api-finance-routes.test.js +633 -0
  9. package/dist/app-api-routes.d.ts +6 -5
  10. package/dist/app-api-routes.js +144 -22
  11. package/dist/app-api-service.d.ts +49 -4
  12. package/dist/app-api-service.js +237 -0
  13. package/dist/app-api-service.test.js +52 -0
  14. package/dist/consent.d.ts +1 -0
  15. package/dist/consent.js +2 -2
  16. package/dist/contracts.d.ts +4 -28
  17. package/dist/contracts.js +3 -10
  18. package/dist/oauth-service.d.ts +6 -1
  19. package/dist/oauth-service.js +41 -4
  20. package/dist/oauth-service.test.js +39 -2
  21. package/dist/routes-openapi.d.ts +1 -25
  22. package/dist/routes-viewer-scopes.test.d.ts +1 -0
  23. package/dist/routes-viewer-scopes.test.js +46 -0
  24. package/dist/routes.d.ts +5 -5
  25. package/dist/routes.js +41 -24
  26. package/dist/runtime-contributor.d.ts +9 -1
  27. package/dist/runtime-contributor.js +25 -2
  28. package/dist/runtime-contributor.test.d.ts +1 -0
  29. package/dist/runtime-contributor.test.js +52 -0
  30. package/dist/runtime-port.d.ts +8 -0
  31. package/dist/runtime-port.js +22 -0
  32. package/dist/session-token-service.d.ts +2 -0
  33. package/dist/session-token-service.js +44 -27
  34. package/dist/session-token-service.test.d.ts +1 -0
  35. package/dist/session-token-service.test.js +157 -0
  36. package/dist/session-token.d.ts +5 -1
  37. package/dist/session-token.js +0 -0
  38. package/dist/session-token.test.js +5 -0
  39. package/dist/voyant.js +136 -1
  40. package/package.json +9 -3
package/dist/consent.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ApiHttpError } from "@voyant-travel/hono";
2
2
  export function computeAppConsent(input) {
3
3
  const normalized = parseReleaseScopes(input.release.normalizedRecord);
4
- const remoteSafe = remoteSafeScopes(input.accessCatalog);
4
+ const remoteSafe = grantableRemoteAppScopes(input.accessCatalog);
5
5
  const operatorGranted = new Set(input.operatorGrantedScopes);
6
6
  const optionalGrantRequest = new Set(input.grantedOptionalScopes ?? []);
7
7
  const requiredScopes = normalized.requestedScopes.filter((scope) => canGrantScope(scope, remoteSafe, operatorGranted));
@@ -27,7 +27,7 @@ export function computeAppConsent(input) {
27
27
  function canGrantScope(scope, remoteSafe, operatorGranted) {
28
28
  return remoteSafe.has(scope) && operatorGranted.has(scope);
29
29
  }
30
- function remoteSafeScopes(catalog) {
30
+ export function grantableRemoteAppScopes(catalog) {
31
31
  const catalogScopes = new Set(catalog.resources.flatMap((resource) => resource.actions.map((action) => `${resource.resource}:${action.action}`)));
32
32
  const remoteSafe = new Set();
33
33
  // A scope is remote-safe when its owning resource is flagged remoteSafe, or
@@ -29,8 +29,8 @@ export declare const appOwnedCustomFieldDeclarationSchema: z.ZodObject<{
29
29
  }, z.core.$strip>>>>;
30
30
  logicalNamespace: z.ZodOptional<z.ZodString>;
31
31
  dataClassification: z.ZodDefault<z.ZodEnum<{
32
- internal: "internal";
33
32
  public: "public";
33
+ internal: "internal";
34
34
  confidential: "confidential";
35
35
  restricted: "restricted";
36
36
  personal: "personal";
@@ -106,8 +106,8 @@ export declare const appManifestSchema: z.ZodPipe<z.ZodRecord<z.ZodString, z.Zod
106
106
  }, z.core.$strip>>>>;
107
107
  logicalNamespace: z.ZodOptional<z.ZodString>;
108
108
  dataClassification: z.ZodDefault<z.ZodEnum<{
109
- internal: "internal";
110
109
  public: "public";
110
+ internal: "internal";
111
111
  confidential: "confidential";
112
112
  restricted: "restricted";
113
113
  personal: "personal";
@@ -134,8 +134,8 @@ export declare const appManifestSchema: z.ZodPipe<z.ZodRecord<z.ZodString, z.Zod
134
134
  }, z.core.$strict>;
135
135
  data: z.ZodObject<{
136
136
  classifications: z.ZodArray<z.ZodEnum<{
137
- internal: "internal";
138
137
  public: "public";
138
+ internal: "internal";
139
139
  confidential: "confidential";
140
140
  restricted: "restricted";
141
141
  personal: "personal";
@@ -222,7 +222,7 @@ export declare const appOAuthAuthorizeQuerySchema: z.ZodObject<{
222
222
  state: z.ZodString;
223
223
  code_challenge: z.ZodString;
224
224
  code_challenge_method: z.ZodLiteral<"S256">;
225
- actor_id: z.ZodString;
225
+ actor_id: z.ZodOptional<z.ZodString>;
226
226
  operator_scopes: z.ZodDefault<z.ZodString>;
227
227
  optional_scopes: z.ZodDefault<z.ZodString>;
228
228
  }, z.core.$strict>;
@@ -238,14 +238,6 @@ export declare const appOAuthTokenSchema: z.ZodPipe<z.ZodDiscriminatedUnion<[z.Z
238
238
  refresh_token: z.ZodString;
239
239
  client_id: z.ZodString;
240
240
  client_secret: z.ZodOptional<z.ZodString>;
241
- }, z.core.$strip>, z.ZodObject<{
242
- grant_type: z.ZodLiteral<"urn:voyant:params:oauth:grant-type:actor-token-exchange">;
243
- installation_id: z.ZodString;
244
- viewer_id: z.ZodString;
245
- viewer_scopes: z.ZodDefault<z.ZodArray<z.ZodString>>;
246
- contextual_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
247
- client_id: z.ZodString;
248
- client_secret: z.ZodOptional<z.ZodString>;
249
241
  }, z.core.$strip>], "grant_type">, z.ZodTransform<{
250
242
  client_secret: string | undefined;
251
243
  grant_type: "authorization_code";
@@ -258,14 +250,6 @@ export declare const appOAuthTokenSchema: z.ZodPipe<z.ZodDiscriminatedUnion<[z.Z
258
250
  grant_type: "refresh_token";
259
251
  refresh_token: string;
260
252
  client_id: string;
261
- } | {
262
- client_secret: string | undefined;
263
- grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
264
- installation_id: string;
265
- viewer_id: string;
266
- viewer_scopes: string[];
267
- client_id: string;
268
- contextual_scopes?: string[] | undefined;
269
253
  }, {
270
254
  grant_type: "authorization_code";
271
255
  code: string;
@@ -278,14 +262,6 @@ export declare const appOAuthTokenSchema: z.ZodPipe<z.ZodDiscriminatedUnion<[z.Z
278
262
  refresh_token: string;
279
263
  client_id: string;
280
264
  client_secret?: string | undefined;
281
- } | {
282
- grant_type: "urn:voyant:params:oauth:grant-type:actor-token-exchange";
283
- installation_id: string;
284
- viewer_id: string;
285
- viewer_scopes: string[];
286
- client_id: string;
287
- contextual_scopes?: string[] | undefined;
288
- client_secret?: string | undefined;
289
265
  }>>;
290
266
  export declare const appCredentialRevocationSchema: z.ZodObject<{
291
267
  installationId: z.ZodString;
package/dist/contracts.js CHANGED
@@ -271,7 +271,9 @@ export const appOAuthAuthorizeQuerySchema = z
271
271
  state: z.string().trim().min(1),
272
272
  code_challenge: z.string().trim().min(1),
273
273
  code_challenge_method: z.literal("S256"),
274
- actor_id: z.string().trim().min(1),
274
+ /** Deprecated input; the route derives the actor from the staff session. */
275
+ actor_id: z.string().trim().min(1).optional(),
276
+ /** Deprecated input; the route derives the grant ceiling from staff scopes. */
275
277
  operator_scopes: z.string().trim().default(""),
276
278
  optional_scopes: z.string().trim().default(""),
277
279
  })
@@ -292,15 +294,6 @@ export const appOAuthTokenSchema = z
292
294
  client_id: z.string().trim().min(1),
293
295
  client_secret: z.string().trim().optional(),
294
296
  }),
295
- z.object({
296
- grant_type: z.literal("urn:voyant:params:oauth:grant-type:actor-token-exchange"),
297
- installation_id: z.string().trim().min(1),
298
- viewer_id: z.string().trim().min(1),
299
- viewer_scopes: z.array(scopeSchema).default([]),
300
- contextual_scopes: z.array(scopeSchema).optional(),
301
- client_id: z.string().trim().min(1),
302
- client_secret: z.string().trim().optional(),
303
- }),
304
297
  ])
305
298
  .transform((input) => ({
306
299
  ...input,
@@ -1,9 +1,11 @@
1
- import type { VoyantAuthContext } from "@voyant-travel/core";
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
4
  export interface AppOAuthServiceOptions {
5
5
  accessCatalog: AccessCatalog;
6
6
  deploymentId: string;
7
+ /** Managed confidential runtimes fail closed unless a client secret is registered. */
8
+ clientAuthentication?: "optional" | "required";
7
9
  now?: () => Date;
8
10
  }
9
11
  export interface AuthorizeAppInput {
@@ -37,6 +39,8 @@ export interface TokenExchangeInput {
37
39
  viewerId: string;
38
40
  viewerScopes: readonly string[];
39
41
  contextualScopes?: readonly string[];
42
+ /** Immutable host context signed into the extension session token. */
43
+ contextConstraint: VoyantAppContextConstraint;
40
44
  clientId: string;
41
45
  clientSecret?: string;
42
46
  }
@@ -64,3 +68,4 @@ export declare function createAppOAuthService(options: AppOAuthServiceOptions):
64
68
  };
65
69
  export declare function intersectAppTokenScopes(...sets: readonly (readonly string[])[]): string[];
66
70
  export declare function readStoredScopes(metadata: Record<string, unknown>): string[] | null;
71
+ export declare function readStoredAppContextConstraint(metadata: Record<string, unknown>): VoyantAppContextConstraint | null;
@@ -55,7 +55,7 @@ export function createAppOAuthService(options) {
55
55
  return { code, state: input.state, redirectUri: input.redirectUri, expiresAt };
56
56
  }
57
57
  async function token(db, input) {
58
- await authenticateClient(db, input.clientId, input.clientSecret);
58
+ await authenticateClient(db, input.clientId, input.clientSecret, options.clientAuthentication === "required");
59
59
  if (input.grantType === "authorization_code")
60
60
  return exchangeCode(db, input);
61
61
  if (input.grantType === "refresh_token")
@@ -82,6 +82,13 @@ export function createAppOAuthService(options) {
82
82
  const scopes = credential.tokenMode === "online"
83
83
  ? (readStoredScopes(credential.encryptedMetadata) ?? [])
84
84
  : await grantedScopes(db, installation.id);
85
+ const appContextConstraint = credential.tokenMode === "online"
86
+ ? readStoredAppContextConstraint(credential.encryptedMetadata)
87
+ : undefined;
88
+ // Every online actor token is minted from an extension session. Missing or
89
+ // malformed context metadata must invalidate it rather than silently widen it.
90
+ if (credential.tokenMode === "online" && !appContextConstraint)
91
+ return null;
85
92
  return {
86
93
  callerType: "app",
87
94
  actor: "staff",
@@ -92,6 +99,7 @@ export function createAppOAuthService(options) {
92
99
  appCredentialGeneration: credential.generation,
93
100
  appTokenMode: credential.tokenMode,
94
101
  appViewerId: credential.viewerId ?? undefined,
102
+ ...(appContextConstraint ? { appContextConstraint } : {}),
95
103
  scopes,
96
104
  };
97
105
  }
@@ -191,7 +199,11 @@ export function createAppOAuthService(options) {
191
199
  credentialHash: sha256Hex(accessToken),
192
200
  // Online tokens are intentionally narrowed; the resolver must honor the
193
201
  // minted set rather than recomputing from installation grants.
194
- encryptedMetadata: { scopeCount: scopes.length, scopes: [...scopes] },
202
+ encryptedMetadata: {
203
+ scopeCount: scopes.length,
204
+ scopes: [...scopes],
205
+ contextConstraint: input.contextConstraint,
206
+ },
195
207
  status: "active",
196
208
  actorId: input.viewerId,
197
209
  viewerId: input.viewerId,
@@ -242,14 +254,17 @@ function tokenResponse(accessToken, refreshToken, expiresAt, scopes, tokenMode)
242
254
  ...(refreshToken ? { refreshToken } : {}),
243
255
  };
244
256
  }
245
- async function authenticateClient(db, appId, clientSecret) {
257
+ async function authenticateClient(db, appId, clientSecret, required) {
246
258
  const [secret] = await db
247
259
  .select()
248
260
  .from(appCredentials)
249
261
  .where(and(eq(appCredentials.appId, appId), eq(appCredentials.kind, "client_secret")))
250
262
  .limit(1);
251
- if (!secret)
263
+ if (!secret) {
264
+ if (required)
265
+ throw oauthError("invalid_client", "Client authentication failed", 401);
252
266
  return;
267
+ }
253
268
  if (!clientSecret || !secret.kmsKeyRef.startsWith("sha256:")) {
254
269
  throw oauthError("invalid_client", "Client authentication failed", 401);
255
270
  }
@@ -382,6 +397,28 @@ export function readStoredScopes(metadata) {
382
397
  return null;
383
398
  return stored.filter((scope) => typeof scope === "string");
384
399
  }
400
+ export function readStoredAppContextConstraint(metadata) {
401
+ const value = metadata.contextConstraint;
402
+ if (!value || typeof value !== "object" || Array.isArray(value))
403
+ return null;
404
+ const record = value;
405
+ const slot = record.slot;
406
+ if (slot !== null && (typeof slot !== "string" || slot.length === 0))
407
+ return null;
408
+ const entity = record.entity;
409
+ if (entity === null)
410
+ return { entity: null, slot };
411
+ if (!entity || typeof entity !== "object" || Array.isArray(entity))
412
+ return null;
413
+ const entityRecord = entity;
414
+ if (typeof entityRecord.type !== "string" ||
415
+ entityRecord.type.length === 0 ||
416
+ typeof entityRecord.id !== "string" ||
417
+ entityRecord.id.length === 0) {
418
+ return null;
419
+ }
420
+ return { entity: { type: entityRecord.type, id: entityRecord.id }, slot };
421
+ }
385
422
  function oauthError(error, description, status = 400) {
386
423
  return new ApiHttpError(description, { status, code: error });
387
424
  }
@@ -1,6 +1,6 @@
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 { appCredentials, appReleases } from "./schema.js";
4
4
  describe("app OAuth online token scope intersection", () => {
5
5
  it("never exceeds either app grants, viewer grants, or contextual restrictions", () => {
6
6
  expect(intersectAppTokenScopes(["bookings:read", "invoices:read"], ["bookings:read", "customers:read"], ["bookings:read", "invoices:read"])).toEqual(["bookings:read"]);
@@ -16,6 +16,22 @@ describe("stored online token scopes", () => {
16
16
  expect(readStoredScopes({ scopeCount: 2 })).toBeNull();
17
17
  expect(readStoredScopes({ scopes: "bookings:read" })).toBeNull();
18
18
  });
19
+ it("fails closed unless immutable extension context metadata is complete", () => {
20
+ expect(readStoredAppContextConstraint({
21
+ contextConstraint: {
22
+ entity: { type: "invoice", id: "invoice_1" },
23
+ slot: "invoice.details.after-summary",
24
+ },
25
+ })).toEqual({
26
+ entity: { type: "invoice", id: "invoice_1" },
27
+ slot: "invoice.details.after-summary",
28
+ });
29
+ expect(readStoredAppContextConstraint({})).toBeNull();
30
+ expect(readStoredAppContextConstraint({ contextConstraint: { entity: { type: "invoice" } } })).toBeNull();
31
+ expect(readStoredAppContextConstraint({
32
+ contextConstraint: { entity: { type: "invoice", id: "invoice_1" }, slot: "" },
33
+ })).toBeNull();
34
+ });
19
35
  });
20
36
  describe("authorization release lifecycle", () => {
21
37
  function dbWithRelease(state) {
@@ -58,3 +74,24 @@ describe("authorization release lifecycle", () => {
58
74
  });
59
75
  });
60
76
  });
77
+ describe("managed client authentication", () => {
78
+ it("fails closed when confidential-client provisioning is absent", async () => {
79
+ const db = Object.assign(Object.create(null), {
80
+ select: () => ({
81
+ from: (table) => ({
82
+ where: () => ({ limit: async () => (table === appCredentials ? [] : []) }),
83
+ }),
84
+ }),
85
+ });
86
+ const service = createAppOAuthService({
87
+ accessCatalog: { resources: [], presets: [] },
88
+ deploymentId: "dep_1",
89
+ clientAuthentication: "required",
90
+ });
91
+ await expect(service.token(db, {
92
+ grantType: "refresh_token",
93
+ refreshToken: "app_refresh_fixture",
94
+ clientId: "app_1",
95
+ })).rejects.toMatchObject({ status: 401, code: "invalid_client" });
96
+ });
97
+ });
@@ -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,21 @@
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
6
  import { type AppsServiceOptions } from "./service.js";
7
7
  type CustomFieldsService = ReturnType<typeof createCustomFieldsService>;
8
8
  type Env = {
9
- Bindings: {
10
- /** Deployment identity; the install lifecycle is scoped to it. */
11
- VOYANT_CLOUD_DEPLOYMENT_ID?: string;
12
- };
13
9
  Variables: {
14
10
  db: PostgresJsDatabase;
11
+ scopes?: string[];
15
12
  };
16
13
  };
17
14
  export interface AppsAdminRouteOptions extends AppsServiceOptions {
18
15
  oauth?: {
19
16
  accessCatalog: AccessCatalog;
20
17
  deploymentId: string;
18
+ clientAuthentication?: "optional" | "required";
21
19
  };
22
20
  /**
23
21
  * Enables the iframe session-token broker. Requires {@link oauth} for the
@@ -39,4 +37,6 @@ export interface AppsAdminRouteOptions extends AppsServiceOptions {
39
37
  customFields?: CustomFieldsService;
40
38
  }
41
39
  export declare function createAppsAdminRoutes(options?: AppsAdminRouteOptions): OpenAPIHono<Env, {}, "/">;
40
+ export declare function resolveViewerRemoteAppScopes(scopes: readonly string[], catalog: AccessCatalog | undefined): string[];
41
+ export declare function resolveOperatorGrantableRemoteAppScopes(scopes: readonly string[], catalog: AccessCatalog): string[];
42
42
  export {};
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";
@@ -21,6 +23,7 @@ export function createAppsAdminRoutes(options = {}) {
21
23
  customFields: options.customFields,
22
24
  });
23
25
  const oauth = options.oauth ? createAppOAuthService(options.oauth) : null;
26
+ const oauthAccessCatalog = options.oauth?.accessCatalog;
24
27
  const sessionTokens = oauth && options.oauth && options.sessionToken
25
28
  ? createAppSessionTokenService({
26
29
  secret: options.sessionToken.secret,
@@ -42,8 +45,9 @@ export function createAppsAdminRoutes(options = {}) {
42
45
  // so it must never be reachable through read-scoped GET requests. The admin
43
46
  // consent UI submits the approval and performs the redirect itself.
44
47
  routes.openapi(authorizeAppOAuthRoute, async (c) => {
45
- if (!oauth)
48
+ if (!oauth || !oauthAccessCatalog) {
46
49
  return c.json({ error: "App OAuth is not configured" }, 501);
50
+ }
47
51
  const body = await parseJsonBody(c, appOAuthAuthorizeQuerySchema);
48
52
  const result = await oauth.authorize(c.get("db"), {
49
53
  appId: body.client_id,
@@ -52,8 +56,8 @@ export function createAppsAdminRoutes(options = {}) {
52
56
  state: body.state,
53
57
  codeChallenge: body.code_challenge,
54
58
  codeChallengeMethod: body.code_challenge_method,
55
- actorId: body.actor_id,
56
- operatorGrantedScopes: splitScopes(body.operator_scopes),
59
+ actorId: requireUserId(c),
60
+ operatorGrantedScopes: resolveOperatorGrantableRemoteAppScopes(c.get("scopes") ?? [], oauthAccessCatalog),
57
61
  grantedOptionalScopes: splitScopes(body.optional_scopes),
58
62
  });
59
63
  const redirectUrl = new URL(result.redirectUri);
@@ -74,22 +78,12 @@ export function createAppsAdminRoutes(options = {}) {
74
78
  clientId: body.client_id,
75
79
  clientSecret: body.client_secret,
76
80
  })
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
- });
81
+ : await oauth.token(c.get("db"), {
82
+ grantType: "refresh_token",
83
+ refreshToken: body.refresh_token,
84
+ clientId: body.client_id,
85
+ clientSecret: body.client_secret,
86
+ });
93
87
  return c.json(token, 200);
94
88
  });
95
89
  routes.openapi(revokeAppInstallationCredentialsRoute, async (c) => {
@@ -111,6 +105,7 @@ export function createAppsAdminRoutes(options = {}) {
111
105
  const issued = await sessionTokens.issue(c.get("db"), {
112
106
  installationId,
113
107
  viewerId,
108
+ viewerScopes: resolveViewerRemoteAppScopes(c.get("scopes") ?? [], options.oauth?.accessCatalog),
114
109
  entity: body.entity ?? null,
115
110
  slot: body.slot ?? null,
116
111
  });
@@ -133,11 +128,10 @@ export function createAppsAdminRoutes(options = {}) {
133
128
  });
134
129
  routes.openapi(installAppRoute, async (c) => {
135
130
  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;
131
+ // A configured managed runtime audience is authoritative so installation
132
+ // identity cannot diverge from later OAuth token audiences. Direct/custom
133
+ // hosts without managed auth may still supply an explicit deployment ID.
134
+ const deploymentId = options.oauth?.deploymentId ?? body.deploymentId ?? options.deploymentId;
141
135
  const result = await installations.install(c.get("db"), {
142
136
  appId: body.appId,
143
137
  releaseId: body.releaseId,
@@ -248,6 +242,29 @@ export function createAppsAdminRoutes(options = {}) {
248
242
  });
249
243
  return routes;
250
244
  }
245
+ export function resolveViewerRemoteAppScopes(scopes, catalog) {
246
+ if (!catalog)
247
+ return [];
248
+ const permissions = permissionStringsToPermissions(scopes);
249
+ return catalog.resources
250
+ .flatMap((resource) => resource.actions
251
+ .filter((action) => (resource.remoteSafe || action.remoteSafe) &&
252
+ hasApiKeyPermission(permissions, resource.resource, action.action, catalog))
253
+ .map((action) => `${resource.resource}:${action.action}`))
254
+ .sort();
255
+ }
256
+ export function resolveOperatorGrantableRemoteAppScopes(scopes, catalog) {
257
+ const grantable = grantableRemoteAppScopes(catalog);
258
+ const permissions = permissionStringsToPermissions(scopes);
259
+ return [...grantable]
260
+ .filter((scope) => {
261
+ const separator = scope.lastIndexOf(":");
262
+ if (separator <= 0)
263
+ return false;
264
+ return hasApiKeyPermission(permissions, scope.slice(0, separator), scope.slice(separator + 1), catalog);
265
+ })
266
+ .sort();
267
+ }
251
268
  function splitScopes(value) {
252
269
  return value
253
270
  .split(/[,\s]+/)
@@ -1 +1,9 @@
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
+ /** Package-owned, provider-neutral managed-auth configuration. */
9
+ export declare function createAppsRuntimePortContribution(host: AppsRuntimeContributorHost): Readonly<Record<string, unknown>>;
@@ -1,3 +1,26 @@
1
- export function createAppsRuntimePortContribution() {
2
- return {};
1
+ import { appsManagedAuthRuntimePort } from "./runtime-port.js";
2
+ function readString(host, key) {
3
+ const value = host.primitives.config.read(undefined, key);
4
+ if (typeof value !== "string")
5
+ return undefined;
6
+ const trimmed = value.trim();
7
+ return trimmed || undefined;
8
+ }
9
+ /** Package-owned, provider-neutral managed-auth configuration. */
10
+ export function createAppsRuntimePortContribution(host) {
11
+ if (host.hasRuntimePort?.(appsManagedAuthRuntimePort))
12
+ return {};
13
+ const runtimeAudience = readString(host, "VOYANT_APP_RUNTIME_AUDIENCE");
14
+ const sessionTokenSigningSecret = readString(host, "VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET");
15
+ if (!runtimeAudience || !sessionTokenSigningSecret)
16
+ return {};
17
+ const ttlInput = readString(host, "VOYANT_APP_SESSION_TOKEN_TTL_SECONDS");
18
+ const sessionTokenTtlSeconds = ttlInput === undefined ? undefined : Number(ttlInput);
19
+ const runtime = {
20
+ runtimeAudience,
21
+ sessionTokenSigningSecret,
22
+ ...(sessionTokenTtlSeconds === undefined ? {} : { sessionTokenTtlSeconds }),
23
+ };
24
+ appsManagedAuthRuntimePort.test(runtime);
25
+ return { [appsManagedAuthRuntimePort.id]: runtime };
3
26
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
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 unless both managed-auth inputs are present", () => {
14
+ expect(createAppsRuntimePortContribution(host({}))).toEqual({});
15
+ expect(createAppsRuntimePortContribution(host({ VOYANT_APP_RUNTIME_AUDIENCE: "deployment-1" }))).toEqual({});
16
+ });
17
+ it("contributes validated provider-neutral managed-auth configuration", () => {
18
+ const contribution = createAppsRuntimePortContribution(host({
19
+ VOYANT_APP_RUNTIME_AUDIENCE: " deployment-1 ",
20
+ VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "s".repeat(32),
21
+ VOYANT_APP_SESSION_TOKEN_TTL_SECONDS: "180",
22
+ }));
23
+ expect(contribution).toEqual({
24
+ [appsManagedAuthRuntimePort.id]: {
25
+ runtimeAudience: "deployment-1",
26
+ sessionTokenSigningSecret: "s".repeat(32),
27
+ sessionTokenTtlSeconds: 180,
28
+ },
29
+ });
30
+ });
31
+ it("does not replace an explicitly host-provided managed-auth port", () => {
32
+ const contribution = createAppsRuntimePortContribution({
33
+ ...host({
34
+ VOYANT_APP_RUNTIME_AUDIENCE: "deployment-from-env",
35
+ VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "s".repeat(32),
36
+ }),
37
+ hasRuntimePort: (port) => port.id === appsManagedAuthRuntimePort.id,
38
+ });
39
+ expect(contribution).toEqual({});
40
+ });
41
+ it("rejects weak signing material and long-lived session tokens", () => {
42
+ expect(() => createAppsRuntimePortContribution(host({
43
+ VOYANT_APP_RUNTIME_AUDIENCE: "deployment-1",
44
+ VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "test-secret",
45
+ }))).toThrow(/at least 32 characters/);
46
+ expect(() => createAppsRuntimePortContribution(host({
47
+ VOYANT_APP_RUNTIME_AUDIENCE: "deployment-1",
48
+ VOYANT_APP_SESSION_TOKEN_SIGNING_SECRET: "s".repeat(32),
49
+ VOYANT_APP_SESSION_TOKEN_TTL_SECONDS: "301",
50
+ }))).toThrow(/1 through 300/);
51
+ });
52
+ });