@voyant-travel/hono 0.128.5 → 0.129.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.
package/dist/app.js CHANGED
@@ -5,7 +5,9 @@ import { createContainer, createEventBus, createQueryRunner, } from "@voyant-tra
5
5
  import { createLinkServiceFactory } from "@voyant-travel/db/links";
6
6
  import { assembleAnonymousPaths } from "./anonymous-paths.js";
7
7
  import { containerToServiceResolver, makeFrameworkLogger, wireWorkflowRuntime, } from "./app-workflows.js";
8
+ import { composeAuthAugmentations } from "./auth-augmentation.js";
8
9
  import { expandApiBundles, isLazyApiBundle, } from "./bundle.js";
10
+ import { assembleClientAuthenticatedRoutes, matchesClientAuthenticatedRoute, } from "./client-authenticated-routes.js";
9
11
  import { mountLazyRoutePaths, mountLazyRoutesAt } from "./lazy-routes.js";
10
12
  import { mountAuthForwarding } from "./lib/auth-forward.js";
11
13
  import { createPathDbSelector } from "./lib/db-selector.js";
@@ -104,6 +106,8 @@ export function mountApp(config) {
104
106
  ...(expanded?.anonymousPaths ?? []),
105
107
  ...lazyPlugins.flatMap((plugin) => plugin.anonymous ?? []),
106
108
  ]);
109
+ const clientAuthenticatedRoutes = assembleClientAuthenticatedRoutes(allModules);
110
+ const composedAuth = composeAuthAugmentations(config.auth, allModules);
107
111
  // When the framework owns the bus, route subscriber-dispatch failures
108
112
  // (including the workflow forwarder) to the reporter — they're otherwise
109
113
  // only console-logged per the fire-and-forget EventBus contract (RFC #1553).
@@ -404,7 +408,9 @@ export function mountApp(config) {
404
408
  const pathname = normalizePathname(new URL(c.req.url).pathname, {
405
409
  basePath: config.basePath,
406
410
  });
407
- const isPublicWrite = pathname.startsWith("/v1/public/") || matchesPublicPath(pathname, anonymousPaths);
411
+ const isPublicWrite = pathname.startsWith("/v1/public/") ||
412
+ matchesPublicPath(pathname, anonymousPaths) ||
413
+ matchesClientAuthenticatedRoute(c.req.method, pathname, clientAuthenticatedRoutes);
408
414
  if (!isPublicWrite)
409
415
  return next();
410
416
  return rateLimit(buildRateLimitPolicy(rateLimitConfig, c.env, "public-write", publicWriteRule))(c, next);
@@ -460,7 +466,7 @@ export function mountApp(config) {
460
466
  app.get("/health", (c) => c.json({ status: "ok" }));
461
467
  // App-owned auth handler (must be before auth middleware — these routes are
462
468
  // public). Forwarding + observability bridging live in `mountAuthForwarding`.
463
- const authHandler = config.auth?.handler;
469
+ const authHandler = composedAuth?.handler;
464
470
  if (authHandler) {
465
471
  mountAuthForwarding(app, authHandler, { reporter, appName });
466
472
  }
@@ -475,7 +481,12 @@ export function mountApp(config) {
475
481
  transactionalPrefixes: txPrefixes,
476
482
  })
477
483
  : config.db;
478
- const authOptions = { publicPaths: anonymousPaths, basePath: config.basePath, auth: config.auth };
484
+ const authOptions = {
485
+ publicPaths: anonymousPaths,
486
+ clientAuthenticatedRoutes,
487
+ basePath: config.basePath,
488
+ auth: composedAuth,
489
+ };
479
490
  // Auth middleware for all other routes
480
491
  app.use("*", requireAuth(dbSource, authOptions));
481
492
  // DB middleware — sets c.var.db for all downstream handlers.
@@ -502,6 +513,7 @@ export function mountApp(config) {
502
513
  basePath: config.basePath,
503
514
  resources: config.accessResources,
504
515
  accessCatalog: config.accessCatalog,
516
+ clientAuthenticatedRoutes,
505
517
  };
506
518
  app.use("/v1/admin/*", requireActor(actorOptions, "staff"));
507
519
  app.use("/v1/public/*", requireActor(actorOptions, "customer", "partner", "supplier"));
@@ -0,0 +1,4 @@
1
+ import type { ApiModule } from "./module.js";
2
+ import type { VoyantAuthIntegration, VoyantBindings } from "./types.js";
3
+ /** Compose trusted package app-token resolvers while preserving host auth. */
4
+ export declare function composeAuthAugmentations<TBindings extends VoyantBindings>(hostAuth: VoyantAuthIntegration<TBindings> | undefined, modules: readonly ApiModule[]): VoyantAuthIntegration<TBindings> | undefined;
@@ -0,0 +1,21 @@
1
+ /** Compose trusted package app-token resolvers while preserving host auth. */
2
+ export function composeAuthAugmentations(hostAuth, modules) {
3
+ const packageResolvers = modules.flatMap((module) => module.authAugmentation ? [module.authAugmentation.resolveAppToken] : []);
4
+ if (packageResolvers.length === 0)
5
+ return hostAuth;
6
+ const hostResolver = hostAuth?.resolveAppToken;
7
+ return {
8
+ ...hostAuth,
9
+ resolveAppToken: async (args) => {
10
+ const hostResult = await hostResolver?.(args);
11
+ if (hostResult)
12
+ return hostResult;
13
+ for (const resolve of packageResolvers) {
14
+ const result = await resolve(args);
15
+ if (result)
16
+ return result;
17
+ }
18
+ return null;
19
+ },
20
+ };
21
+ }
@@ -0,0 +1,8 @@
1
+ import type { ApiModule, ClientAuthenticatedRoute } from "./module.js";
2
+ export interface AbsoluteClientAuthenticatedRoute {
3
+ method: ClientAuthenticatedRoute["method"];
4
+ path: string;
5
+ }
6
+ /** Resolve trusted module declarations to exact, app-local admin routes. */
7
+ export declare function assembleClientAuthenticatedRoutes(modules: readonly ApiModule[]): AbsoluteClientAuthenticatedRoute[];
8
+ export declare function matchesClientAuthenticatedRoute(method: string, pathname: string, routes: readonly AbsoluteClientAuthenticatedRoute[]): boolean;
@@ -0,0 +1,20 @@
1
+ /** Resolve trusted module declarations to exact, app-local admin routes. */
2
+ export function assembleClientAuthenticatedRoutes(modules) {
3
+ const routes = new Map();
4
+ for (const module of modules) {
5
+ const prefix = `/v1/admin/${module.module.name}`;
6
+ for (const route of module.clientAuthenticated ?? []) {
7
+ const trimmed = route.path.trim();
8
+ if (!trimmed.startsWith("/") || trimmed.includes(":") || trimmed.includes("*")) {
9
+ throw new Error(`clientAuthenticated route for module "${module.module.name}" must be a concrete relative path beginning with "/"`);
10
+ }
11
+ const path = `${prefix}${trimmed === "/" ? "" : trimmed.replace(/\/+$/g, "")}`;
12
+ const resolved = { method: route.method, path };
13
+ routes.set(`${resolved.method} ${resolved.path}`, resolved);
14
+ }
15
+ }
16
+ return [...routes.values()].sort((left, right) => `${left.method} ${left.path}`.localeCompare(`${right.method} ${right.path}`));
17
+ }
18
+ export function matchesClientAuthenticatedRoute(method, pathname, routes) {
19
+ return routes.some((route) => route.method === method.toUpperCase() && route.path === pathname);
20
+ }
@@ -1,7 +1,9 @@
1
1
  import type { MiddlewareHandler } from "hono";
2
+ import type { AbsoluteClientAuthenticatedRoute } from "../client-authenticated-routes.js";
2
3
  import { type DbSource, type VoyantAuthIntegration, type VoyantBindings, type VoyantVariables } from "../types.js";
3
4
  export declare function requireAuth<TBindings extends VoyantBindings>(dbSource: DbSource<TBindings>, opts?: {
4
5
  publicPaths?: string[];
6
+ clientAuthenticatedRoutes?: readonly AbsoluteClientAuthenticatedRoute[];
5
7
  basePath?: string;
6
8
  auth?: VoyantAuthIntegration<TBindings>;
7
9
  }): MiddlewareHandler<{
@@ -3,6 +3,7 @@ import { API_KEY_AUDIENCES, permissionsToStrings } from "@voyant-travel/types/ap
3
3
  import { and, eq, sql } from "drizzle-orm";
4
4
  import { constantTimeEqual, sha256Base64Url, sha256Hex } from "../auth/crypto.js";
5
5
  import { extractBearerToken, verifySession } from "../auth/session-jwt.js";
6
+ import { matchesClientAuthenticatedRoute } from "../client-authenticated-routes.js";
6
7
  import { tryGetExecutionCtx } from "../lib/execution-ctx.js";
7
8
  import { matchesPublicPath, normalizePathname } from "../lib/public-paths.js";
8
9
  import { selectDbFactory, } from "../types.js";
@@ -152,6 +153,8 @@ function applyAuthContext(c, auth) {
152
153
  c.set("appTokenMode", auth.appTokenMode);
153
154
  if (auth.appViewerId)
154
155
  c.set("appViewerId", auth.appViewerId);
156
+ if (auth.appContextConstraint)
157
+ c.set("appContextConstraint", auth.appContextConstraint);
155
158
  }
156
159
  export function requireAuth(dbSource, opts) {
157
160
  const publicPaths = opts?.publicPaths ?? [];
@@ -167,6 +170,9 @@ export function requireAuth(dbSource, opts) {
167
170
  const isHealthCheck = p === "/health";
168
171
  if (isPublicAuth || isHealthCheck)
169
172
  return next();
173
+ if (matchesClientAuthenticatedRoute(c.req.method, p, opts?.clientAuthenticatedRoutes ?? [])) {
174
+ return next();
175
+ }
170
176
  if (matchesPublicPath(p, publicPaths)) {
171
177
  if (p.startsWith("/v1/public/")) {
172
178
  c.set("actor", "customer");
@@ -283,8 +289,11 @@ export function requireAuth(dbSource, opts) {
283
289
  await lease.release();
284
290
  }
285
291
  }
286
- // Strategy 3: Remote app access token support
287
- if (token && opts?.auth?.resolveAppToken) {
292
+ // Strategy 3: Remote app access token support. App grants authorize only
293
+ // the installation-scoped App API; never resolve them on admin/public
294
+ // surfaces where similarly named scopes may protect broader host routes.
295
+ const isAppApi = p === "/v1/app" || p.startsWith("/v1/app/");
296
+ if (token && isAppApi && opts?.auth?.resolveAppToken) {
288
297
  const lease = acquireRequestDb(c, dbFactory);
289
298
  try {
290
299
  const resolved = await opts.auth.resolveAppToken({
@@ -1,6 +1,7 @@
1
1
  import type { Actor } from "@voyant-travel/core";
2
2
  import { type AccessCatalog } from "@voyant-travel/types/api-keys";
3
3
  import type { MiddlewareHandler } from "hono";
4
+ import type { AbsoluteClientAuthenticatedRoute } from "../client-authenticated-routes.js";
4
5
  import type { VoyantBindings, VoyantVariables } from "../types.js";
5
6
  /**
6
7
  * Staff-session RBAC enforcement (member-rbac-rfc, voyant#2085). Enforced **by
@@ -24,6 +25,8 @@ export interface RequireActorOptions {
24
25
  authorization?: "coarse" | "route";
25
26
  }[];
26
27
  accessCatalog?: AccessCatalog;
28
+ /** Exact admin protocol routes that authenticate their client in-band. */
29
+ clientAuthenticatedRoutes?: readonly AbsoluteClientAuthenticatedRoute[];
27
30
  }
28
31
  /**
29
32
  * Guards a route surface by actor type.
@@ -1,4 +1,5 @@
1
1
  import { hasApiKeyPermission, permissionStringsToPermissions, } from "@voyant-travel/types/api-keys";
2
+ import { matchesClientAuthenticatedRoute } from "../client-authenticated-routes.js";
2
3
  import { normalizePathname } from "../lib/public-paths.js";
3
4
  /**
4
5
  * Route surfaces exempt from the coarse method+path permission guard because
@@ -98,12 +99,15 @@ export function requireActor(...args) {
98
99
  return async (c, next) => {
99
100
  if (c.req.method === "OPTIONS")
100
101
  return next();
102
+ const pathname = normalizePathname(new URL(c.req.url).pathname, {
103
+ basePath: options.basePath,
104
+ });
105
+ if (matchesClientAuthenticatedRoute(c.req.method, pathname, options.clientAuthenticatedRoutes ?? [])) {
106
+ return next();
107
+ }
101
108
  if (c.get("callerType") === "api_key" ||
102
109
  c.get("callerType") === "app" ||
103
110
  c.get("callerType") === "internal") {
104
- const pathname = normalizePathname(new URL(c.req.url).pathname, {
105
- basePath: options.basePath,
106
- });
107
111
  const { resource, authorization } = authorizationForRequest(pathname, options);
108
112
  // Coarse-guard-exempt surfaces. `_meta` (capability discovery) and `mcp`
109
113
  // (the agent tool server) are dispatch surfaces where authorization is
@@ -139,9 +143,6 @@ export function requireActor(...args) {
139
143
  // resource (e.g. `_meta`) stay open until a module is explicitly covered.
140
144
  if (actor === "staff" && c.get("callerType") === "session" && isStaffRbacEnforced(c.env)) {
141
145
  const scopes = c.get("scopes");
142
- const pathname = normalizePathname(new URL(c.req.url).pathname, {
143
- basePath: options.basePath,
144
- });
145
146
  const { resource, authorization } = authorizationForRequest(pathname, options);
146
147
  if (resource && authorization !== "route" && !isCoarseGuardExempt(resource)) {
147
148
  const actions = apiKeyPermissionActionsForMethod(c.req.method, pathname);
package/dist/module.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Extension, Module } from "@voyant-travel/core";
2
2
  import type { Hono } from "hono";
3
3
  import type { LazyApiRoutes, LazyRoutesLoader } from "./lazy-routes.js";
4
+ import type { VoyantAuthIntegration } from "./types.js";
4
5
  export interface ApiModule {
5
6
  module: Module;
6
7
  /** Staff-facing routes — mounted at `/v1/admin/{module.name}`. */
@@ -56,6 +57,23 @@ export interface ApiModule {
56
57
  * require a `staff` actor.
57
58
  */
58
59
  anonymous?: boolean | readonly string[];
60
+ /**
61
+ * Concrete admin endpoints whose credential is validated by the route itself
62
+ * instead of by the staff-session middleware. Each declaration is matched by
63
+ * exact HTTP method and exact path; it never opens sibling or child routes.
64
+ *
65
+ * This is intentionally narrower than `anonymous`: use it only for protocol
66
+ * endpoints such as an OAuth token exchange where client authentication is
67
+ * part of the request body or Authorization header. The handler remains
68
+ * responsible for validating that client credential.
69
+ */
70
+ clientAuthenticated?: readonly ClientAuthenticatedRoute[];
71
+ /**
72
+ * Trusted package-owned additions to the host authentication pipeline.
73
+ * Augmentations are composed after any host app-token resolver and do not
74
+ * replace staff session handling or any other host auth hook.
75
+ */
76
+ authAugmentation?: ApiAuthAugmentation;
59
77
  /**
60
78
  * Absolute API path prefixes whose requests must be served by the
61
79
  * transaction-capable db client (ADR-0008). For modules whose
@@ -69,6 +87,14 @@ export interface ApiModule {
69
87
  */
70
88
  transactionalPaths?: readonly string[];
71
89
  }
90
+ export interface ClientAuthenticatedRoute {
91
+ method: "POST";
92
+ /** Concrete path relative to `/v1/admin/{module.name}`. */
93
+ path: string;
94
+ }
95
+ export interface ApiAuthAugmentation {
96
+ resolveAppToken: NonNullable<VoyantAuthIntegration["resolveAppToken"]>;
97
+ }
72
98
  export interface ApiExtension {
73
99
  extension: Extension;
74
100
  /** Staff-facing routes — mounted at `/v1/admin/{extension.module}`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyant-travel/hono",
3
- "version": "0.128.5",
3
+ "version": "0.129.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -130,17 +130,17 @@
130
130
  "drizzle-orm": "^0.45.2",
131
131
  "hono": "^4.12.27",
132
132
  "zod": "^4.4.3",
133
- "@voyant-travel/core": "^0.126.1",
134
- "@voyant-travel/db": "^0.114.12",
133
+ "@voyant-travel/db": "^0.114.13",
134
+ "@voyant-travel/core": "^0.127.0",
135
135
  "@voyant-travel/types": "^0.109.4",
136
136
  "@voyant-travel/utils": "^0.107.1",
137
- "@voyant-travel/workflows": "^0.122.4"
137
+ "@voyant-travel/workflows": "^0.122.6"
138
138
  },
139
139
  "devDependencies": {
140
140
  "typescript": "^6.0.3",
141
141
  "vitest": "^4.1.9",
142
142
  "@voyant-travel/voyant-typescript-config": "^0.1.0",
143
- "@voyant-travel/workflows-orchestrator": "^0.122.4"
143
+ "@voyant-travel/workflows-orchestrator": "^0.122.6"
144
144
  },
145
145
  "files": [
146
146
  "dist"