@@ -14,12 +14,7 @@ import type {
1414} from "shared" ;
1515import { AppManager } from "../app" ;
1616import { CacheManager } from "../cache" ;
17- import {
18- getCurrentUserId ,
19- runInUserContext ,
20- ServiceContext ,
21- type UserContext ,
22- } from "../context" ;
17+ import { getCurrentUserId , runInUserContext , ServiceContext } from "../context" ;
2318import type { PluginContext } from "../core/plugin-context" ;
2419import { AppKitError , AuthenticationError } from "../errors" ;
2520import { createLogger } from "../logging/logger" ;
@@ -43,19 +38,55 @@ import type {
4338
4439const logger = createLogger ( "plugin" ) ;
4540
46- /**
47- * Symbol used to expose the UserContext from an asUser() proxy.
48- * Allows wrapWithAsUser in appkit.ts to retrieve the context and
49- * wrap export methods in runInUserContext().
50- */
51- export const USER_CONTEXT_SYMBOL = Symbol ( "appkit.userContext" ) ;
52-
5341/**
5442 * OTel context key for marking OBO dev mode fallback.
5543 * Set when asUser() is called in development mode without a user token.
5644 */
5745const DEV_OBO_FALLBACK_KEY = createContextKey ( "appkit.devOboFallback" ) ;
5846
47+ /**
48+ * Returns true if `value` is a plain object literal (not an array, Date,
49+ * class instance, etc.). Used to decide whether to recurse into nested
50+ * export shapes when wrapping functions.
51+ *
52+ * @internal exported so the AppKit core can reuse the same predicate for
53+ * its `bindExportMethods` walk; not part of the public package surface.
54+ */
55+ export function isPlainObject (
56+ value : unknown ,
57+ ) : value is Record < string , unknown > {
58+ if ( typeof value !== "object" || value === null ) return false ;
59+ const proto = Object . getPrototypeOf ( value ) ;
60+ return proto === Object . prototype || proto === null ;
61+ }
62+
63+ /**
64+ * Returns a deep copy of `exports` where every function has been replaced
65+ * with `wrap(fn)`, walking into nested plain objects.
66+ *
67+ * Used by the asUser proxy to make the user context follow function
68+ * references that escape the proxy via `exports()`. The original input is
69+ * not mutated, so plugins that memoize `exports()` are safe — each call
70+ * through the proxy yields an independent, freshly wrapped view.
71+ */
72+ function wrapExportFunctions (
73+ exports : Record < string , unknown > ,
74+ wrap : ( fn : ( ...a : unknown [ ] ) => unknown ) => ( ...a : unknown [ ] ) => unknown ,
75+ ) : Record < string , unknown > {
76+ const result : Record < string , unknown > = { } ;
77+ for ( const key of Object . keys ( exports ) ) {
78+ const val = exports [ key ] ;
79+ if ( typeof val === "function" ) {
80+ result [ key ] = wrap ( val as ( ...a : unknown [ ] ) => unknown ) ;
81+ } else if ( isPlainObject ( val ) ) {
82+ result [ key ] = wrapExportFunctions ( val , wrap ) ;
83+ } else {
84+ result [ key ] = val ;
85+ }
86+ }
87+ return result ;
88+ }
89+
5990/**
6091 * Returns true if the current execution is an OBO dev mode fallback
6192 * (asUser() was called but fell back to service principal due to missing token).
@@ -403,30 +434,18 @@ export abstract class Plugin<
403434 const userEmail = req . header ( "x-forwarded-email" ) ;
404435 const isDev = process . env . NODE_ENV === "development" ;
405436
406- // In local development, skip user impersonation
407- // since there's no user token available
437+ // In local development, skip user impersonation since there's no user
438+ // token available. Mark execution as OBO dev fallback via OTel context
439+ // so telemetry can distinguish intended OBO calls from regular SP calls.
408440 if ( ! token && isDev ) {
409441 logger . warn (
410442 "asUser() called without user token in development mode. Skipping user impersonation." ,
411443 ) ;
412444
413- // Return a proxy that marks execution as OBO dev fallback via OTel context,
414- // so telemetry spans can distinguish intended OBO calls from regular SP calls
415- return new Proxy ( this , {
416- get : ( target , prop , receiver ) => {
417- const value = Reflect . get ( target , prop , receiver ) ;
418- if ( typeof value !== "function" ) return value ;
419- if ( typeof prop === "string" && EXCLUDED_FROM_PROXY . has ( prop ) )
420- return value ;
421-
422- return ( ...args : unknown [ ] ) => {
423- const ctx = otelContext
424- . active ( )
425- . setValue ( DEV_OBO_FALLBACK_KEY , true ) ;
426- return otelContext . with ( ctx , ( ) => value . apply ( target , args ) ) ;
427- } ;
428- } ,
429- } ) as this;
445+ return this . _createAsUserProxy ( ( fn ) => ( ...args ) => {
446+ const ctx = otelContext . active ( ) . setValue ( DEV_OBO_FALLBACK_KEY , true ) ;
447+ return otelContext . with ( ctx , ( ) => fn ( ...args ) ) ;
448+ } ) ;
430449 }
431450
432451 if ( ! token ) {
@@ -446,34 +465,55 @@ export abstract class Plugin<
446465 userEmail ?? undefined ,
447466 ) ;
448467
449- // Return a proxy that wraps method calls in user context
450- return this . _createUserContextProxy ( userContext ) ;
468+ return this . _createAsUserProxy (
469+ ( fn ) =>
470+ ( ...args ) =>
471+ runInUserContext ( userContext , ( ) => fn ( ...args ) ) ,
472+ ) ;
451473 }
452474
453475 /**
454- * Creates a proxy that wraps method calls in a user context.
455- * This allows all plugin methods to automatically use the user's
456- * Databricks credentials.
476+ * Creates a proxy of `this` where every method call — and every function
477+ * in the result of `exports()` — runs inside `wrapCall`.
478+ *
479+ * `wrapCall` decides the per-call scope. Two strategies are used today:
480+ * - real OBO: fn => (...args) => runInUserContext(userContext, () => fn(...args))
481+ * - dev fallback: fn => (...args) => otelContext.with(DEV_OBO_FALLBACK_KEY=true, () => fn(...args))
482+ *
483+ * `exports` is intercepted because methods captured in the returned
484+ * exports object never re-enter the proxy's `get` trap. Wrapping them
485+ * here is the only way to make the user context follow function
486+ * references back out of the plugin.
457487 */
458- private _createUserContextProxy ( userContext : UserContext ) : this {
488+ private _createAsUserProxy (
489+ wrapCall : (
490+ fn : ( ...a : unknown [ ] ) => unknown ,
491+ ) => ( ...a : unknown [ ] ) => unknown ,
492+ ) : this {
459493 return new Proxy ( this , {
460494 get : ( target , prop , receiver ) => {
461- // Expose userContext via symbol so wrapWithAsUser can wrap exports
462- if ( prop === USER_CONTEXT_SYMBOL ) return userContext ;
463-
464495 const value = Reflect . get ( target , prop , receiver ) ;
465496
466- if ( typeof value !== "function" ) {
497+ if ( typeof value !== "function" ) return value ;
498+ if ( typeof prop === "string" && EXCLUDED_FROM_PROXY . has ( prop ) )
467499 return value ;
468- }
469500
470- if ( typeof prop === "string" && EXCLUDED_FROM_PROXY . has ( prop ) ) {
471- return value ;
501+ if ( prop === "exports" ) {
502+ return ( ) => {
503+ const raw = ( value as ( ) => unknown ) . call ( target ) ;
504+ if ( raw == null ) return { } ;
505+ // Callable exports (e.g. files, jobs) manage per-call asUser
506+ // themselves; leave them untouched.
507+ if ( typeof raw === "function" ) return raw ;
508+ if ( isPlainObject ( raw ) ) {
509+ return wrapExportFunctions ( raw , wrapCall ) ;
510+ }
511+ return raw ;
512+ } ;
472513 }
473514
474- return ( ...args : unknown [ ] ) => {
475- return runInUserContext ( userContext , ( ) => value . apply ( target , args ) ) ;
476- } ;
515+ const fn = ( value as ( ...a : unknown [ ] ) => unknown ) . bind ( target ) ;
516+ return wrapCall ( fn ) ;
477517 } ,
478518 } ) as this;
479519 }
0 commit comments