@voyant-travel/apps 0.7.0 → 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 (38) hide show
  1. package/dist/api-runtime.d.ts +23 -11
  2. package/dist/api-runtime.js +38 -1
  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 +58 -8
  6. package/dist/app-api-contracts.js +102 -9
  7. package/dist/app-api-finance-routes.test.js +482 -2
  8. package/dist/app-api-routes.d.ts +6 -5
  9. package/dist/app-api-routes.js +131 -22
  10. package/dist/app-api-service.d.ts +31 -1
  11. package/dist/app-api-service.js +162 -0
  12. package/dist/consent.d.ts +1 -0
  13. package/dist/consent.js +2 -2
  14. package/dist/contracts.d.ts +1 -25
  15. package/dist/contracts.js +3 -10
  16. package/dist/oauth-service.d.ts +6 -1
  17. package/dist/oauth-service.js +41 -4
  18. package/dist/oauth-service.test.js +39 -2
  19. package/dist/routes-openapi.d.ts +1 -25
  20. package/dist/routes-viewer-scopes.test.d.ts +1 -0
  21. package/dist/routes-viewer-scopes.test.js +46 -0
  22. package/dist/routes.d.ts +5 -5
  23. package/dist/routes.js +41 -24
  24. package/dist/runtime-contributor.d.ts +9 -1
  25. package/dist/runtime-contributor.js +25 -2
  26. package/dist/runtime-contributor.test.d.ts +1 -0
  27. package/dist/runtime-contributor.test.js +52 -0
  28. package/dist/runtime-port.d.ts +8 -0
  29. package/dist/runtime-port.js +22 -0
  30. package/dist/session-token-service.d.ts +2 -0
  31. package/dist/session-token-service.js +44 -27
  32. package/dist/session-token-service.test.d.ts +1 -0
  33. package/dist/session-token-service.test.js +157 -0
  34. package/dist/session-token.d.ts +5 -1
  35. package/dist/session-token.js +0 -0
  36. package/dist/session-token.test.js +5 -0
  37. package/dist/voyant.js +95 -1
  38. package/package.json +8 -3
@@ -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
+ });
@@ -0,0 +1,8 @@
1
+ export interface AppsManagedAuthRuntime {
2
+ /** Stable audience shared by authorization, installations, and session tokens. */
3
+ runtimeAudience: string;
4
+ /** Host-owned HMAC material for short-lived extension session tokens. */
5
+ sessionTokenSigningSecret: string;
6
+ sessionTokenTtlSeconds?: number;
7
+ }
8
+ export declare const appsManagedAuthRuntimePort: import("@voyant-travel/core/project").VoyantPort<AppsManagedAuthRuntime>;
@@ -0,0 +1,22 @@
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 (typeof runtime.sessionTokenSigningSecret !== "string" ||
12
+ runtime.sessionTokenSigningSecret.length < 32) {
13
+ throw new TypeError("apps.managed-auth sessionTokenSigningSecret must contain at least 32 characters.");
14
+ }
15
+ if (runtime.sessionTokenTtlSeconds !== undefined &&
16
+ (!Number.isInteger(runtime.sessionTokenTtlSeconds) ||
17
+ runtime.sessionTokenTtlSeconds <= 0 ||
18
+ runtime.sessionTokenTtlSeconds > 300)) {
19
+ throw new TypeError("apps.managed-auth sessionTokenTtlSeconds must be an integer from 1 through 300 when provided.");
20
+ }
21
+ },
22
+ });
@@ -15,6 +15,8 @@ export interface AppSessionTokenServiceOptions {
15
15
  export interface IssueAppSessionTokenInput {
16
16
  installationId: string;
17
17
  viewerId: string;
18
+ /** Current host-authenticated viewer permissions. */
19
+ viewerScopes?: readonly string[];
18
20
  entity?: AppSessionTokenEntity | null;
19
21
  slot?: string | null;
20
22
  }
@@ -13,7 +13,7 @@
13
13
  * Both paths write `app_audit_events` so issuance and exchange are auditable.
14
14
  */
15
15
  import { ApiHttpError } from "@voyant-travel/hono";
16
- import { and, eq, isNull } from "drizzle-orm";
16
+ import { and, eq, gt, isNull } from "drizzle-orm";
17
17
  import { appAuditEvents, appInstallations, appSessionTokens, } from "./schema.js";
18
18
  import { signAppSessionToken, verifyAppSessionToken, } from "./session-token.js";
19
19
  export function createAppSessionTokenService(options) {
@@ -25,6 +25,7 @@ export function createAppSessionTokenService(options) {
25
25
  installationId: installation.id,
26
26
  deploymentId: installation.deploymentId,
27
27
  viewerId: input.viewerId,
28
+ viewerScopes: input.viewerScopes ?? [],
28
29
  entity: input.entity ?? null,
29
30
  slot: input.slot ?? null,
30
31
  };
@@ -63,37 +64,53 @@ export function createAppSessionTokenService(options) {
63
64
  });
64
65
  }
65
66
  const { claims } = verified;
66
- // Single-use: consume the jti atomically. A row that is missing or already
67
- // consumed is a replay and is refused before any credential is minted.
68
- const consumed = await db
69
- .update(appSessionTokens)
70
- .set({ consumedAt: now(), consumedByActorId: claims.sub })
71
- .where(and(eq(appSessionTokens.jti, claims.jti), isNull(appSessionTokens.consumedAt)))
72
- .returning({ id: appSessionTokens.id });
73
- if (consumed.length === 0) {
74
- throw new ApiHttpError("Session token has already been used", {
75
- status: 401,
76
- code: "app_session_token_replayed",
67
+ return db.transaction(async (tx) => {
68
+ const installation = await requireActiveInstallation(tx, claims.installationId);
69
+ if (installation.appId !== claims.aud || installation.deploymentId !== claims.deploymentId) {
70
+ throw new ApiHttpError("Session token installation context changed", {
71
+ status: 401,
72
+ code: "app_session_token_installation_mismatch",
73
+ });
74
+ }
75
+ // Client authentication and token construction happen before consumption,
76
+ // but in the same transaction. If authentication/minting fails, or another
77
+ // exchange wins the JTI race, every credential side effect rolls back.
78
+ const tokens = await options.oauth.token(tx, {
79
+ grantType: "urn:voyant:params:oauth:grant-type:actor-token-exchange",
80
+ installationId: installation.id,
81
+ viewerId: claims.sub,
82
+ viewerScopes: intersectRequestedScopes(claims.viewerScopes, input.viewerScopes),
83
+ contextualScopes: input.contextualScopes,
84
+ contextConstraint: { entity: claims.entity, slot: claims.slot },
85
+ clientId: input.clientId,
86
+ clientSecret: input.clientSecret,
77
87
  });
78
- }
79
- const installation = await requireActiveInstallation(db, claims.installationId);
80
- const tokens = await options.oauth.token(db, {
81
- grantType: "urn:voyant:params:oauth:grant-type:actor-token-exchange",
82
- installationId: installation.id,
83
- viewerId: claims.sub,
84
- viewerScopes: input.viewerScopes,
85
- contextualScopes: input.contextualScopes,
86
- clientId: input.clientId,
87
- clientSecret: input.clientSecret,
88
- });
89
- await audit(db, installation, claims.sub, "session-token.exchanged", {
90
- jti: claims.jti,
91
- slot: claims.slot,
88
+ const consumed = await tx
89
+ .update(appSessionTokens)
90
+ .set({ consumedAt: now(), consumedByActorId: claims.sub })
91
+ .where(and(eq(appSessionTokens.jti, claims.jti), eq(appSessionTokens.installationId, installation.id), eq(appSessionTokens.appId, claims.aud), eq(appSessionTokens.deploymentId, claims.deploymentId), eq(appSessionTokens.viewerId, claims.sub), isNull(appSessionTokens.consumedAt), gt(appSessionTokens.expiresAt, now())))
92
+ .returning({ id: appSessionTokens.id });
93
+ if (consumed.length === 0) {
94
+ throw new ApiHttpError("Session token has already been used", {
95
+ status: 401,
96
+ code: "app_session_token_replayed",
97
+ });
98
+ }
99
+ await audit(tx, installation, claims.sub, "session-token.exchanged", {
100
+ jti: claims.jti,
101
+ slot: claims.slot,
102
+ entityType: claims.entity?.type ?? null,
103
+ entityId: claims.entity?.id ?? null,
104
+ });
105
+ return tokens;
92
106
  });
93
- return tokens;
94
107
  }
95
108
  return { issue, exchange };
96
109
  }
110
+ function intersectRequestedScopes(trustedViewerScopes, requestedScopes) {
111
+ const trusted = new Set(trustedViewerScopes);
112
+ return Array.from(new Set(requestedScopes.filter((scope) => trusted.has(scope)))).sort();
113
+ }
97
114
  async function requireActiveInstallation(db, installationId) {
98
115
  const [installation] = await db
99
116
  .select()
@@ -0,0 +1 @@
1
+ export {};